Compare commits

..
73 Commits
Author SHA1 Message Date
Mattias Walström 980b9dac7c rauc: Enable streaming of upgrades 2026-06-27 08:41:22 +02:00
Mattias Walström e32b72d1ee yangerd: containers: widen event debounce to avoid podman contention 2026-06-27 08:41:22 +02:00
Mattias Walström 62b890f7b6 yangerd: Fix containers 2026-06-27 08:41:22 +02:00
Mattias Walström ce4184e048 yangerd: Change containers from polled to reactive 2026-06-27 08:41:22 +02:00
Mattias Walström 013932ed06 multicast: stream MAC sender continuously like the IPv4 sender 2026-06-27 08:41:22 +02:00
Mattias Walström 8c6f9cec49 test: operational_all: Poll for stuff to get removed from yangerd 2026-06-27 08:41:22 +02:00
Mattias Walström 27dc42622e test: operational_all: Try to adapt to yangerd 2026-06-27 08:41:22 +02:00
Mattias Walström f4de58a54c yangerd: Fix bugs with containers and boot order in CI 2026-06-27 08:41:22 +02:00
Mattias Walström 4899279ed2 test: dhcp: client_basic: Adapt to changes by yangerd 2026-06-27 08:41:22 +02:00
Mattias Walström 42c8dc0346 test: upgrade: Make more robust using yangerd 2026-06-27 08:41:22 +02:00
Mattias Walström a2937e13eb test: speed_duplex_coppar: Adapt to yangerd 2026-06-27 08:41:22 +02:00
Mattias Walström b35f4df8a5 yangerd: Add arm 32bit support
Int => int64
2026-06-27 08:41:22 +02:00
Mattias Walström 8890274e90 yangerd: add missing files 2026-06-27 08:41:22 +02:00
Mattias Walström 127bbdfc4f REMOVE THIS: TEMPORARY DISABLE PTP TESTS 2026-06-27 08:41:22 +02:00
Mattias Walström 8fb9e9b68e test: mdns_allow_deny: Adapt to yangerd behaviour changes 2026-06-27 08:41:22 +02:00
Mattias Walström 7d9319b8e4 test: lldp_admin_status: Adapt to yangerd changes 2026-06-27 08:41:22 +02:00
Mattias Walström 7f4c4b63ee dhcp: server_subnets: Adapt to yanger behaviour changes 2026-06-27 08:41:22 +02:00
Mattias Walström beb2439922 test: firewall: ipv6-zone-migration: Adapt to yanger behaviour changes 2026-06-27 08:41:22 +02:00
Mattias Walström 7dc1811389 test: rip_multihop: Adapt to yangerd changes 2026-06-27 08:41:22 +02:00
Mattias Walström 01b9c04f95 test: ntp: client_stratum_selection: Adapt to yangerd behaviour 2026-06-27 08:41:22 +02:00
Mattias Walström 8b60e447f3 yangerd: Fix ntp and lldp status 2026-06-27 08:41:22 +02:00
Mattias Walström d90d0d2a9c yangerd: Return {} instead of 404 if no match 2026-06-27 08:41:22 +02:00
Mattias Walström 6a669e6d92 statd: Adapt to yanger keys on module:container path
Instead of relying of the runtime xpath, which can be just /system-state
for example, resulting on no yangerd match.
2026-06-27 08:41:22 +02:00
Mattias Walström 155f74e968 test: ospf_default_route_advertise: Make test more robust
FRR can sometimes be pretty slow to actually install the route in
the kernel. Increase the timeout of must_reach.
2026-06-27 08:41:22 +02:00
Mattias Walström 7f0b88be63 test: route_pref_ospf: Update test spec 2026-06-27 08:41:22 +02:00
Mattias Walström 05d4598ac9 test: ntp_client: Update test spec 2026-06-27 08:41:22 +02:00
Mattias Walström 79ec51860b test: hostname: adapt to yanger 2026-06-27 08:41:22 +02:00
Mattias Walström 51aa4d26b7 test: ospf_point_to_multipoint: Adapt to yanger 2026-06-27 08:41:22 +02:00
Mattias Walström a33214d67f test: spec: Allow to generate test specification without logical topology 2026-06-27 08:41:22 +02:00
Mattias Walström cff66cd74c test: upgrade: add missing readme 2026-06-27 08:41:21 +02:00
Mattias Walström b662984f09 test: operational_all: Harden to catch more
- Test so the value in operational actually is correct
- Move test to last in suite to see that there are no stray
  data in operational (yangerd)
2026-06-27 08:41:21 +02:00
Mattias Walström 39114f89dd yangerd: Add missing files 2026-06-27 08:41:21 +02:00
Mattias Walström 0b193cb5bd test: upgrade: Adapt to yangerd 2026-06-27 08:41:21 +02:00
Mattias Walström 98022e2e5f test: route_pref_ospf: Adapt to yangerd 2026-06-27 08:41:21 +02:00
Mattias Walström 52c7e4e90e test: ospf_unnumbered_interface: Adapt to new yangerd 2026-06-27 08:41:21 +02:00
Mattias Walström f50f6add9c test: veth_delete: Adapt to new yangerd
Operational is now not instantly availible, is some lag.
2026-06-27 08:41:21 +02:00
Mattias Walström 5071a1f177 test: ntp_client: Adapt to new yangerd daemon 2026-06-27 08:41:21 +02:00
Mattias Walström ee5499501c test: stp_basic: Stabilize test
With the new yangerd it failed 100% of the times, but it could fail
before as well, but that whould be very rare. Wait until operational
has been updated.
2026-06-27 08:41:21 +02:00
Mattias Walström e9ff748df9 tests: Adapt to new yangerd 2026-06-27 08:41:20 +02:00
Mattias Walström bf850f59d7 yangerd: Add support for getting ARP neighbors 2026-06-27 08:39:48 +02:00
Mattias Walström 76de3427ff test: syslog: property_filter: Adapt to changes required by yangerd
The test got an unexpected input in one of its logs:

'May 22 11:18:32 test-00-03-00 yangerd[3677]: SIGHUP: triggering immediate re-poll'
limit the string grepping for to be more exact.
2026-06-27 08:39:48 +02:00
Mattias Walström 6a78def525 test: upgrade: Add adaptions needed for yangerd 2026-06-27 08:39:48 +02:00
Mattias Walström 59fbb26dca test: upgrade: Add adaptions needed for yangerd
Specially the change:
`
-            oper = target.get_dict("/system-state/software")
+            oper = target.get_dict("/ietf-system:system-state/software")
`
i feel is strange, since only the backend for sysrepo has changed, but the
new syntax is actually correct, unsure why it has worked before.
2026-06-27 08:39:48 +02:00
Mattias Walström 56b7904d1a statd: Remove old code 2026-06-27 08:39:48 +02:00
Mattias Walström 3e23997289 yanger: Remove code duplication and fix upgrade
Remove a lot of duplication and move to seperate libs in internal/

Also, instead of polling upgrade status using src/rauc-installation-status,
hook into the dbus monitor.
2026-06-27 08:39:48 +02:00
Mattias Walström c7ab211e51 yangerd: Add WiFi implementation 2026-06-27 08:39:48 +02:00
Mattias Walström 32bed66267 yangerd: Add backoff if ip batch work have crashed 2026-06-27 08:39:48 +02:00
Mattias Walström ca7447dfc9 yangerd: Do not start in runlevel S 2026-06-27 08:39:48 +02:00
Mattias Walström dc7a86c604 test: iface_enable_disable: Simplify test 2026-06-27 08:39:48 +02:00
Mattias Walström f305bd3527 test: wireguard_roadwarrior: Adapt to the new polled world in yangerd 2026-06-27 08:39:48 +02:00
Mattias Walström 73f6b52a70 yangerd: Add wireguard support 2026-06-27 08:39:48 +02:00
Mattias Walström 9e403831b3 yangerd: fix stp status 2026-06-27 08:39:48 +02:00
Mattias Walström b40f64492f yangerd: Send sighup to yangerd on config change
This is to force yangerd to repoll polled things
2026-06-27 08:39:48 +02:00
Mattias Walström 2dffad67b7 test: usb: adapt to new yangerd 2026-06-27 08:39:48 +02:00
Mattias Walström 0cd598d67c test: containers: host_commands/test.py: Adapt to new yangerd
Slightly slower to detect changes
2026-06-27 08:39:48 +02:00
Mattias Walström 6ce28a7e40 Fix containers, reactive now. 2026-06-27 08:39:48 +02:00
Mattias Walström e938c2dce2 test: case: ntp: Adapt tests for polled yangerd 2026-06-27 08:39:48 +02:00
Mattias Walström 61a7c85d96 test: dhcp: server_subnets: Add longer timeout for polling
This since NTP status is polled in yangerd.
2026-06-27 08:39:48 +02:00
Mattias Walström 72d3777d3f Fix ntp and dns status 2026-06-27 08:39:48 +02:00
Mattias Walström 02db44bb82 FRR does not send delete on OSPF route change
Handle this.
2026-06-27 08:39:48 +02:00
Mattias Walström 4cc9751c7b Fix interface and bridge race conditions 2026-06-27 08:39:48 +02:00
Mattias Walström 7b45f2589c zapi: Fix deletion of routes 2026-06-27 08:39:48 +02:00
Mattias Walström ad67a5aacf yangerd: Continues 2026-06-27 08:39:48 +02:00
Mattias Walström fdbf00ca76 Fix routing and ip addresses 2026-06-27 08:39:47 +02:00
Mattias Walström 4131167ede update doc 2026-06-27 08:39:47 +02:00
Mattias Walström 281adb45b6 statd: Include new yangerd backend for operational 2026-06-27 08:39:47 +02:00
Mattias Walström 35f8d6e3d5 move yanger 2026-06-27 08:39:47 +02:00
Mattias Walström 1e932f09c1 yangerd: Initial 2026-06-27 08:39:47 +02:00
Mattias Walström 6d27f67b37 test: firewall: Make tests more robust 2026-06-27 08:39:47 +02:00
Mattias Walström ce4dd2d3e3 test: system: Make tests more robust 2026-06-27 08:39:47 +02:00
Mattias Walström 0deff2515c firewall: simplify 2026-06-27 08:39:47 +02:00
Mattias Walström 30907b0343 TEMP: Update yanger spec 2026-06-27 08:39:47 +02:00
Mattias Walström 89fee60663 TEMP: WiP document 2026-06-27 08:39:47 +02:00
913 changed files with 313661 additions and 522 deletions
+3 -4
View File
@@ -110,10 +110,9 @@ config INFIX_OEM_PATH
directory (absolute path) and the Infix post-build.sh will call `git
describe -C $INFIX_OEM_PATH`.
Note: the OS version (VERSION, VERSION_ID, BUILD_ID in
/etc/os-release) is always derived from `git describe`. The global
variable INFIX_RELEASE does not change it; it only labels the release
channel (IMAGE_VERSION) and names the published artifacts.
Note: for release builds the global variable INFIX_RELEASE overrides
the version information derived from `git describe`. However, the
GIT version is always saved as the BUILD_ID in /etc/os-releases.
endmenu
+1
View File
@@ -244,6 +244,7 @@ CONFIG_FW_CFG_SYSFS_CMDLINE=y
CONFIG_BLK_DEV_NULL_BLK=y
CONFIG_BLK_DEV_LOOP=y
CONFIG_BLK_DEV_RAM=y
CONFIG_BLK_DEV_NBD=y
CONFIG_VIRTIO_BLK=y
CONFIG_BLK_DEV_SD=y
CONFIG_BLK_DEV_SR=y
+2 -1
View File
@@ -27,7 +27,7 @@ BR2_ROOTFS_OVERLAY="${BR2_EXTERNAL_INFIX_PATH}/board/common/rootfs ${BR2_EXTERNA
BR2_ROOTFS_POST_BUILD_SCRIPT="${BR2_EXTERNAL_INFIX_PATH}/board/common/post-build.sh"
BR2_LINUX_KERNEL=y
BR2_LINUX_KERNEL_CUSTOM_VERSION=y
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.37"
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.36"
BR2_LINUX_KERNEL_USE_CUSTOM_CONFIG=y
BR2_LINUX_KERNEL_CUSTOM_CONFIG_FILE="${BR2_EXTERNAL_INFIX_PATH}/board/aarch64/linux_defconfig"
BR2_LINUX_KERNEL_INSTALL_TARGET=y
@@ -162,6 +162,7 @@ BR2_PACKAGE_CURIOS_HTTPD=y
BR2_PACKAGE_CURIOS_NFTABLES=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
BR2_PACKAGE_YANGERD=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
+2 -1
View File
@@ -27,7 +27,7 @@ BR2_ROOTFS_OVERLAY="${BR2_EXTERNAL_INFIX_PATH}/board/common/rootfs ${BR2_EXTERNA
BR2_ROOTFS_POST_BUILD_SCRIPT="${BR2_EXTERNAL_INFIX_PATH}/board/common/post-build.sh"
BR2_LINUX_KERNEL=y
BR2_LINUX_KERNEL_CUSTOM_VERSION=y
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.37"
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.36"
BR2_LINUX_KERNEL_USE_CUSTOM_CONFIG=y
BR2_LINUX_KERNEL_CUSTOM_CONFIG_FILE="${BR2_EXTERNAL_INFIX_PATH}/board/aarch64/linux_defconfig"
BR2_LINUX_KERNEL_INSTALL_TARGET=y
@@ -131,6 +131,7 @@ BR2_PACKAGE_NETD=y
BR2_PACKAGE_CONFD_TEST_MODE=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
BR2_PACKAGE_YANGERD=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
+2 -1
View File
@@ -28,7 +28,7 @@ BR2_ROOTFS_OVERLAY="${BR2_EXTERNAL_INFIX_PATH}/board/common/rootfs ${BR2_EXTERNA
BR2_ROOTFS_POST_BUILD_SCRIPT="${BR2_EXTERNAL_INFIX_PATH}/board/common/post-build.sh"
BR2_LINUX_KERNEL=y
BR2_LINUX_KERNEL_CUSTOM_VERSION=y
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.37"
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.36"
BR2_LINUX_KERNEL_USE_CUSTOM_CONFIG=y
BR2_LINUX_KERNEL_CUSTOM_CONFIG_FILE="${BR2_EXTERNAL_INFIX_PATH}/board/arm/linux_defconfig"
BR2_LINUX_KERNEL_INSTALL_TARGET=y
@@ -149,6 +149,7 @@ BR2_PACKAGE_NETD=y
BR2_PACKAGE_CONFD_TEST_MODE=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
BR2_PACKAGE_YANGERD=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
+2 -1
View File
@@ -28,7 +28,7 @@ BR2_ROOTFS_OVERLAY="${BR2_EXTERNAL_INFIX_PATH}/board/common/rootfs ${BR2_EXTERNA
BR2_ROOTFS_POST_BUILD_SCRIPT="${BR2_EXTERNAL_INFIX_PATH}/board/common/post-build.sh"
BR2_LINUX_KERNEL=y
BR2_LINUX_KERNEL_CUSTOM_VERSION=y
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.37"
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.36"
BR2_LINUX_KERNEL_USE_CUSTOM_CONFIG=y
BR2_LINUX_KERNEL_CUSTOM_CONFIG_FILE="${BR2_EXTERNAL_INFIX_PATH}/board/arm/linux_defconfig"
BR2_LINUX_KERNEL_INSTALL_TARGET=y
@@ -129,6 +129,7 @@ BR2_PACKAGE_NETD=y
BR2_PACKAGE_CONFD_TEST_MODE=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
BR2_PACKAGE_YANGERD=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
+1
View File
@@ -181,6 +181,7 @@ BR2_PACKAGE_CONFD=y
BR2_PACKAGE_NETD=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
BR2_PACKAGE_YANGERD=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
+3 -1
View File
@@ -26,7 +26,7 @@ BR2_ROOTFS_OVERLAY="${BR2_EXTERNAL_INFIX_PATH}/board/common/rootfs ${BR2_EXTERNA
BR2_ROOTFS_POST_BUILD_SCRIPT="board/qemu/x86_64/post-build.sh ${BR2_EXTERNAL_INFIX_PATH}/board/common/post-build.sh"
BR2_LINUX_KERNEL=y
BR2_LINUX_KERNEL_CUSTOM_VERSION=y
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.37"
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.36"
BR2_LINUX_KERNEL_USE_CUSTOM_CONFIG=y
BR2_LINUX_KERNEL_CUSTOM_CONFIG_FILE="${BR2_EXTERNAL_INFIX_PATH}/board/x86_64/linux_defconfig"
BR2_LINUX_KERNEL_INSTALL_TARGET=y
@@ -110,6 +110,7 @@ BR2_PACKAGE_RAUC=y
BR2_PACKAGE_RAUC_DBUS=y
BR2_PACKAGE_RAUC_GPT=y
BR2_PACKAGE_RAUC_NETWORK=y
BR2_PACKAGE_RAUC_STREAMING=y
BR2_PACKAGE_RAUC_JSON=y
BR2_PACKAGE_SYSKLOGD=y
BR2_PACKAGE_SYSKLOGD_LOGGER=y
@@ -156,6 +157,7 @@ BR2_PACKAGE_CURIOS_HTTPD=y
BR2_PACKAGE_CURIOS_NFTABLES=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
BR2_PACKAGE_YANGERD=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
+2 -1
View File
@@ -26,7 +26,7 @@ BR2_ROOTFS_OVERLAY="${BR2_EXTERNAL_INFIX_PATH}/board/common/rootfs ${BR2_EXTERNA
BR2_ROOTFS_POST_BUILD_SCRIPT="board/qemu/x86_64/post-build.sh ${BR2_EXTERNAL_INFIX_PATH}/board/common/post-build.sh"
BR2_LINUX_KERNEL=y
BR2_LINUX_KERNEL_CUSTOM_VERSION=y
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.37"
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.18.36"
BR2_LINUX_KERNEL_USE_CUSTOM_CONFIG=y
BR2_LINUX_KERNEL_CUSTOM_CONFIG_FILE="${BR2_EXTERNAL_INFIX_PATH}/board/x86_64/linux_defconfig"
BR2_LINUX_KERNEL_INSTALL_TARGET=y
@@ -128,6 +128,7 @@ BR2_PACKAGE_NETD=y
BR2_PACKAGE_CONFD_TEST_MODE=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
BR2_PACKAGE_YANGERD=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
+3 -34
View File
@@ -3,28 +3,12 @@ Change Log
All notable changes to the project are documented in this file.
[v26.06.0][] - 2026-07-01
[v26.06.0][UNRELEASED]
-------------------------
> [!NOTE]
> Noteworthy changes and additions in this release:
>
> **🌐 Web Interface:** Infix gets its first-ever web interface! Browse live
> status and a full operational tree, handle common tasks from curated
> configuration pages, and drop into a YANG tree editor for everything else.
> A maintenance section covers firmware upgrade, backup & restore, and more.
>
> **📶 Wi-Fi Roaming & Mesh:** Access points sharing an SSID can hand clients
> off seamlessly with 802.11k/v/r, form a cable-free 802.11s mesh backhaul,
> and steer dual-band clients onto the faster 5/6 GHz band.
>
> **🗓️ System Scheduling:** Reusable time schedules based on ietf-schedule
> (RFC 9922) let features like scheduled reboot and software update checks
> run on a recurring, cron-style calendar.
### Changes
- Upgrade Linux kernel to 6.18.37 (LTS)
- Upgrade Linux kernel to 6.18.36 (LTS)
- Upgrade Buildroot to 2025.02.15 (LTS)
- Add basic web interface: static status pages and a tree view of operational
status. Curated configuration pages for some common tasks and a YANG tree
@@ -43,13 +27,6 @@ All notable changes to the project are documented in this file.
iCalendar recurrence grouping pruned to cron-expressible rules. Schedules
are reusable time-specs; features (`scheduled-reboot`,
`software/check-update`) trigger off them via a schedule reference
- Configuring multiple BSS (more than one SSID) on a single Wi-Fi radio now
requires an explicitly configured MAC address per BSS
- New operational `advertised-pmd-types` leaf-list on each Ethernet interface,
exposing the link modes currently advertised, to compare against the
`supported-pmd-types` introduced in v26.05.0
- Release assets no longer ship separate `.sha256` checksum files; the
download page now publishes a SHA-256 checksum for each asset directly
### Fixes
@@ -62,14 +39,6 @@ All notable changes to the project are documented in this file.
default `admin` user no permission to read or write the file from shell
- Fix admin url shown for HTTP/HTTPS links in <https://network.local> browser,
used pre-conflict resolution hostname.local, instead of hostname-2.local
- Fix unreadable per-port temperature sensor names in `show hardware` on
Marvell based switches: each sensor is now named after the front-panel port
it serves (e.g. `e1`, `e2`) instead of a raw device-tree path. `show
system` also reports a representative SoC temperature on CN913x platforms
- Fix missing `contact` and `location` settings in operational status; the
values were configurable but never returned on RESTCONF/NETCONF reads
- Fix spurious YANG validation warnings, for NTP and WireGuard configuration,
emitted on every NETCONF session and schema load
[wifi]: https://www.kernelkit.org/infix/latest/wifi/
@@ -2205,7 +2174,7 @@ Supported YANG models in addition to those used by sysrepo and netopeer:
- N/A
[buildroot]: https://buildroot.org/
[UNRELEASED]: https://github.com/kernelkit/infix/compare/v26.06.0...HEAD
[UNRELEASED]: https://github.com/kernelkit/infix/compare/v26.05.0...HEAD
[v26.06.0]: https://github.com/kernelkit/infix/compare/v26.05.0...v26.06.0
[v26.05.0]: https://github.com/kernelkit/infix/compare/v26.04.0...v26.05.0
[v26.04.0]: https://github.com/kernelkit/infix/compare/v26.03.0...v26.04.0
+4 -6
View File
@@ -4,12 +4,10 @@ oem-dir := $(call qstrip,$(INFIX_OEM_PATH))
INFIX_TOPDIR = $(if $(oem-dir),$(oem-dir),$(BR2_EXTERNAL_INFIX_PATH))
# Unless the user specifies an explicit build id, source it from git.
# Exclude the moving 'latest*' tags so the version always resolves to a
# real release tag, see issue #1524. The build id is also the version
# shown to users; INFIX_RELEASE only labels the release channel and names
# artifacts (see INFIX_ARTIFACT below).
export INFIX_BUILD_ID ?= $(shell git -C $(INFIX_TOPDIR) describe --dirty --always --tags --exclude 'latest*')
export INFIX_VERSION = $(INFIX_BUILD_ID)
# The build id also becomes the image version, unless an official
# release is being built.
export INFIX_BUILD_ID ?= $(shell git -C $(INFIX_TOPDIR) describe --dirty --always --tags)
export INFIX_VERSION = $(if $(INFIX_RELEASE),$(INFIX_RELEASE),$(INFIX_BUILD_ID))
export INFIX_ARTIFACT = $(call qstrip,$(INFIX_IMAGE_ID)$(if $(INFIX_RELEASE),-$(INFIX_RELEASE)))
INFIX_CFLAGS:=-Wall -Werror -Wextra -Wno-unused-parameter -Wformat=2 -Wformat-overflow=2 -Winit-self -Wstrict-overflow=4 -Wno-format-truncation -Wno-format-nonliteral
+1
View File
@@ -13,6 +13,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/curios-httpd/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/curios-nftables/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/gencert/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/statd/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/yangerd/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/factory/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/faux/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/finit/Config.in"
+7
View File
@@ -0,0 +1,7 @@
config BR2_PACKAGE_YANGERD
bool "yangerd"
depends on BR2_PACKAGE_HOST_GO_TARGET_ARCH_SUPPORTS
help
Operational data daemon for YANG/NETCONF/RESTCONF.
Replaces Python yanger scripts with a persistent Go daemon
serving operational data over a Unix socket IPC protocol.
+3
View File
@@ -0,0 +1,3 @@
service <> name:yangerd log:prio:daemon.notice,tag:yangerd \
env:-/etc/default/yangerd \
[2345] yangerd -- Operational data daemon
+35
View File
@@ -0,0 +1,35 @@
################################################################################
#
# yangerd
#
################################################################################
YANGERD_VERSION = 1.0.0
YANGERD_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/yangerd
YANGERD_SITE_METHOD = local
YANGERD_GOMOD = github.com/kernelkit/infix/src/yangerd
YANGERD_LICENSE = BSD-2-Clause
YANGERD_LICENSE_FILES = LICENSE
YANGERD_REDISTRIBUTE = NO
YANGERD_BUILD_TARGETS = cmd/yangerd cmd/yangerctl
YANGERD_INSTALL_BINS = yangerd yangerctl
define YANGERD_INSTALL_EXTRA
$(INSTALL) -D -m 0644 $(YANGERD_PKGDIR)/yangerd.conf \
$(FINIT_D)/available/yangerd.conf
ln -sf ../available/yangerd.conf $(FINIT_D)/enabled/yangerd.conf
$(INSTALL) -d $(TARGET_DIR)/etc/default
echo '# yangerd build-time feature flags (generated by yangerd.mk)' \
> $(TARGET_DIR)/etc/default/yangerd
echo 'YANGERD_ENABLE_WIFI=$(if $(BR2_PACKAGE_IW),true,false)' \
>> $(TARGET_DIR)/etc/default/yangerd
echo 'YANGERD_ENABLE_CONTAINERS=$(if $(BR2_PACKAGE_PODMAN),true,false)' \
>> $(TARGET_DIR)/etc/default/yangerd
echo 'YANGERD_ENABLE_GPS=$(if $(BR2_PACKAGE_GPSD),true,false)' \
>> $(TARGET_DIR)/etc/default/yangerd
echo 'YANGERD_LOG_LEVEL=deug' >> $(TARGET_DIR)/etc/default/yangerd
endef
YANGERD_POST_INSTALL_TARGET_HOOKS += YANGERD_INSTALL_EXTRA
$(eval $(golang-package))
@@ -1,4 +1,4 @@
From f614327b6eca20319f5eee20ac954ea8d92276e4 Mon Sep 17 00:00:00 2001
From a3165eb664d65da173e91e8b159b8daf9a1285c7 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Tue, 19 Sep 2023 18:38:10 +0200
Subject: [PATCH 01/50] net: phy: marvell10g: Support firmware loading on
@@ -1,4 +1,4 @@
From 7d453cb838611f44d7566b6174ae95a5d2265fa1 Mon Sep 17 00:00:00 2001
From 0ee3021b04ec64d96b90d9246f37d3d7a1f8f187 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Tue, 21 Nov 2023 20:15:24 +0100
Subject: [PATCH 02/50] net: phy: marvell10g: Fix power-up when strapped to
@@ -1,4 +1,4 @@
From 4b08a36e3107c4018ddf3a19760cc87d02281bd7 Mon Sep 17 00:00:00 2001
From 7e5a199bca36c5d8b1fd3875bbb8343c86db73ea Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Wed, 15 Nov 2023 20:58:42 +0100
Subject: [PATCH 03/50] net: phy: marvell10g: Add LED support for 88X3310
@@ -1,4 +1,4 @@
From e5e81eb1172db22be7c83efc8071108d0d42565e Mon Sep 17 00:00:00 2001
From 62a1468f5c6fdaba5074fa7555e3b7888762e4e7 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Tue, 12 Dec 2023 09:51:05 +0100
Subject: [PATCH 04/50] net: phy: marvell10g: Support LEDs tied to a single
@@ -1,4 +1,4 @@
From 1f735096f1b8920ee6f21bf60a4c2d3f6376adeb Mon Sep 17 00:00:00 2001
From 38519daa6941dc275e3efaaba699b6441f1c19e2 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Wed, 27 Mar 2024 10:10:19 +0100
Subject: [PATCH 05/50] net: phy: Do not resume PHY when attaching
@@ -1,4 +1,4 @@
From e38d2d2f07f55819d2191d4282dc2d4688075d7d Mon Sep 17 00:00:00 2001
From e11c3a1cf1979f558a1a1c06ed68a99b89273aea Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Mon, 4 Mar 2024 16:47:28 +0100
Subject: [PATCH 06/50] net: bridge: avoid classifying unknown multicast as
@@ -1,4 +1,4 @@
From a9b80b26c96e5492ab32a72ee36e3321a2b90ee9 Mon Sep 17 00:00:00 2001
From 9186aa368a4b580090d14ee1d9218ac33bc0aa5f Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Tue, 5 Mar 2024 06:44:41 +0100
Subject: [PATCH 07/50] net: bridge: Ignore router ports when forwarding L2
@@ -1,4 +1,4 @@
From 01e41d87efa1b63d292f0acfe4d1fb0e39baab20 Mon Sep 17 00:00:00 2001
From 9641f50de1c780b0e45be9095ab28cbbc4c70011 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Thu, 4 Apr 2024 16:36:30 +0200
Subject: [PATCH 08/50] net: bridge: drop delay for applying strict multicast
@@ -1,4 +1,4 @@
From 944c77d075c8e0655bd3a28e96892261465a5cf4 Mon Sep 17 00:00:00 2001
From d14c3ad03563ac803180c18e6917eb506bea7079 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Thu, 16 May 2024 14:51:54 +0200
Subject: [PATCH 09/50] net: bridge: Differentiate MDB additions from
@@ -1,4 +1,4 @@
From 3f58061ab8f869a6432a1256ee7422bb157b624e Mon Sep 17 00:00:00 2001
From 4d2335aac65d03c004d27cd27f077362588851e2 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Fri, 24 Nov 2023 23:29:55 +0100
Subject: [PATCH 10/50] nvmem: layouts: onie-tlv: Let device probe even when
@@ -1,4 +1,4 @@
From f492d4efac3a65bca4d53e383336c36113e28a8b Mon Sep 17 00:00:00 2001
From 1f727779b31e9dec09b669d07655371c45fbc64b Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Sun, 11 Aug 2024 11:27:35 +0200
Subject: [PATCH 11/50] net: usb: r8152: add r8153b support for link/activity
@@ -1,4 +1,4 @@
From a2448b84e5483b5b91b46ac31b27609352fd9ed6 Mon Sep 17 00:00:00 2001
From 8053206624b54fb84715342e7b921e0939d594a7 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Sun, 10 Aug 2025 18:52:54 +0200
Subject: [PATCH 12/50] arm64: dts: mediatek: mt7986a: rename BPi R3 ports to
@@ -1,4 +1,4 @@
From c4ac90dcc5aefbb704c70ebbfe041dee683b882b Mon Sep 17 00:00:00 2001
From 5b4047d082c93431f77819b894e29a13acebd787 Mon Sep 17 00:00:00 2001
From: Mattias Walström <lazzer@gmail.com>
Date: Wed, 20 Aug 2025 21:38:24 +0200
Subject: [PATCH 13/50] drm/panel-simple: Add a timing for the Raspberry Pi 7"
@@ -1,4 +1,4 @@
From 8da101ec2429ba0bf1410346260f9a7ddfb0bdbe Mon Sep 17 00:00:00 2001
From 3efdf41a3464704c4ffb99ea94266dc900f7b556 Mon Sep 17 00:00:00 2001
From: Mattias Walström <lazzer@gmail.com>
Date: Thu, 21 Aug 2025 11:20:23 +0200
Subject: [PATCH 14/50] input:touchscreen:edt-ft5x06: Add polled mode
@@ -1,4 +1,4 @@
From 93646f890903157bb78ef369ddf9d99a9161de0d Mon Sep 17 00:00:00 2001
From ce445d44a5e6ae84214986cbccef985a240e831d Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Tue, 12 Mar 2024 10:27:24 +0100
Subject: [PATCH 15/50] [FIX] net: dsa: mv88e6xxx: Fix timeout on waiting for
@@ -1,4 +1,4 @@
From 29cfa1b5ffba41728dc5e0991d1ee27b903d569c Mon Sep 17 00:00:00 2001
From 57aa43c667a99e08c11a6141f80c068021585829 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Wed, 27 Mar 2024 15:52:43 +0100
Subject: [PATCH 16/50] net: dsa: mv88e6xxx: Improve indirect register access
@@ -1,4 +1,4 @@
From a7fa9a45329781519702d46ba05815433fff4acc Mon Sep 17 00:00:00 2001
From 70c247b71e2f2ede0cae581b0558bd09cd85275e Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Mon, 22 Apr 2024 23:18:01 +0200
Subject: [PATCH 17/50] net: dsa: mv88e6xxx: Honor ports being managed via
@@ -1,4 +1,4 @@
From cda7100b0a29e6a9bd29a67ff9db3bcccb72065c Mon Sep 17 00:00:00 2001
From b9b9cc025114c8bb8546012d575cbb9759ce6975 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Wed, 24 Apr 2024 22:41:04 +0200
Subject: [PATCH 18/50] net: dsa: mv88e6xxx: Limit rsvd2cpu policy to user
@@ -1,4 +1,4 @@
From ccf9f9262e8570cfd522b841465f3edf76b3447a Mon Sep 17 00:00:00 2001
From 071b831a6ba7c5de2cc05738b595cea10e2eb068 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Tue, 28 May 2024 10:38:42 +0200
Subject: [PATCH 19/50] net: dsa: tag_dsa: Use tag priority as initial
@@ -1,4 +1,4 @@
From 3200169701ef4d9afdea35355b3fe41d6f363134 Mon Sep 17 00:00:00 2001
From 625493833fcdc4268a46ce4b2c59eefb1a0a0be0 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Tue, 16 Jan 2024 16:00:55 +0100
Subject: [PATCH 20/50] net: dsa: Support MDB memberships whose L2 addresses
@@ -1,4 +1,4 @@
From dea9ff6c0bdf7c3e4abc2fb507d2ab4b94dabb63 Mon Sep 17 00:00:00 2001
From c59543ced113c4a66ccf07cbfac5dc7dd6cb253e Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Thu, 21 Mar 2024 19:12:15 +0100
Subject: [PATCH 21/50] net: dsa: Support EtherType based priority overrides
@@ -1,4 +1,4 @@
From 1dc01d7420e3d511babfbdeda742d93527d72abb Mon Sep 17 00:00:00 2001
From fb04b75bc53f6b1b27f91f4ed21013b293e8577c Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Fri, 22 Mar 2024 16:15:43 +0100
Subject: [PATCH 22/50] net: dsa: mv88e6xxx: Support EtherType based priority
@@ -1,4 +1,4 @@
From caed0d150badbb040e403975645bf09ba3f44e76 Mon Sep 17 00:00:00 2001
From 32e460b9fbc7b25817c05b9ec2c01f4827f7cca7 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Tue, 28 May 2024 11:04:22 +0200
Subject: [PATCH 23/50] net: dsa: mv88e6xxx: Add mqprio qdisc support
@@ -1,4 +1,4 @@
From 1bd5b0e8a5574209b386d6d8bc1cf3bcf2e4fd5e Mon Sep 17 00:00:00 2001
From 30f92e6d97e8d6d8fe36e872e627b28fcf24fdac Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Wed, 29 May 2024 13:20:41 +0200
Subject: [PATCH 24/50] net: dsa: mv88e6xxx: Use VLAN prio over IP when both
@@ -1,4 +1,4 @@
From 7894882d70e8223939a6d9a0e15cc6bbc08ae33c Mon Sep 17 00:00:00 2001
From a9055d21d1237a5449d5f410d3a0c3d8953a1965 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Tue, 26 Nov 2024 19:45:59 +0100
Subject: [PATCH 25/50] [FIX] net: dsa: mv88e6xxx: Trap locally terminated
@@ -1,4 +1,4 @@
From 09cb11eaf5cc754274620ff6eaedf768c9605e44 Mon Sep 17 00:00:00 2001
From 7fb7bc76b39078049d7965bef1bc976f9e1142c5 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Thu, 16 Jan 2025 12:35:12 +0100
Subject: [PATCH 26/50] net: dsa: mv88e6xxx: collapse disabled state into
@@ -1,4 +1,4 @@
From 7127fd4194cde754cbc9ea869c9e65e335701d95 Mon Sep 17 00:00:00 2001
From 2a8ff34ccf08f85f985ebea74b6eb47a64e2f247 Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Wed, 12 Feb 2025 22:03:14 +0100
Subject: [PATCH 27/50] net: dsa: mv88e6xxx: Only activate LAG offloading when
@@ -1,4 +1,4 @@
From c2f3d9277e12e7622651b521af8b43b1fe036cf0 Mon Sep 17 00:00:00 2001
From 348c281450bf30875877045d628f8a56ef7896a1 Mon Sep 17 00:00:00 2001
From: Mattias Walström <lazzer@gmail.com>
Date: Wed, 14 Jan 2026 18:22:41 +0100
Subject: [PATCH 28/50] net: dsa: mv88e6xxx: Add LED support for 6393X
@@ -1,4 +1,4 @@
From ac98be8b6fd1b6afa264c68685d5dc82d73da1f8 Mon Sep 17 00:00:00 2001
From 653db5307cd9d69d6b14e163f02226c83e26bd05 Mon Sep 17 00:00:00 2001
From: Mattias Walström <lazzer@gmail.com>
Date: Thu, 15 Jan 2026 22:47:37 +0100
Subject: [PATCH 29/50] wifi: brcmfmac: support deletion and recreation of
@@ -1,4 +1,4 @@
From 9f1110fe13c878c49d28859f27bd90bc1b77f7bc Mon Sep 17 00:00:00 2001
From 6445e629e19b9380dc96c9e5bfd15bbf706e1311 Mon Sep 17 00:00:00 2001
From: Mattias Walström <lazzer@gmail.com>
Date: Mon, 19 Jan 2026 13:06:53 +0100
Subject: [PATCH 30/50] wifi: brcmfmac: check connection state before querying
@@ -1,4 +1,4 @@
From 188203d0d4eeeb607b29efa344280189c2566f30 Mon Sep 17 00:00:00 2001
From 2b970f903b571a3f3ca6e6043b2608c30825463f Mon Sep 17 00:00:00 2001
From: Mattias Walström <lazzer@gmail.com>
Date: Tue, 20 Jan 2026 20:12:10 +0100
Subject: [PATCH 31/50] wifi: brcmfmac: suppress log spam for
@@ -1,4 +1,4 @@
From c02051fdfbcedf99bac490bcb0358774774e2bcf Mon Sep 17 00:00:00 2001
From eab236832c10d0a0c05fcab145abfe4267857957 Mon Sep 17 00:00:00 2001
From: Mattias Walström <lazzer@gmail.com>
Date: Tue, 20 Jan 2026 20:18:45 +0100
Subject: [PATCH 32/50] wifi: brcmfmac: reduce log noise during AP to station
@@ -1,4 +1,4 @@
From 613dec2af90a208b2bb453150cb1851de07d51fb Mon Sep 17 00:00:00 2001
From 8bad16ffcea19fde114ac772399140ded40c6b91 Mon Sep 17 00:00:00 2001
From: Mattias Walström <lazzer@gmail.com>
Date: Tue, 17 Feb 2026 21:59:59 +0100
Subject: [PATCH 33/50] net: phy: air_en8811h: add OF device table for
@@ -1,4 +1,4 @@
From 5df424aa25bcfd2f0309fbd05670f0b613219456 Mon Sep 17 00:00:00 2001
From 105d92fb189196e292f1ec60bc6f4abf883e1d3d Mon Sep 17 00:00:00 2001
From: Dave Stevenson <dave.stevenson@raspberrypi.com>
Date: Fri, 20 Sep 2024 12:05:18 +0100
Subject: [PATCH 34/50] drm: vc4: dsi: enable video and then retry failed
@@ -1,4 +1,4 @@
From 88e744997f5c6ff9c9383fbe568c925c11715896 Mon Sep 17 00:00:00 2001
From 85515b63fa683de8025f439c0ba290b535cada59 Mon Sep 17 00:00:00 2001
From: Dave Stevenson <dave.stevenson@raspberrypi.com>
Date: Wed, 8 Jun 2022 17:23:47 +0100
Subject: [PATCH 35/50] drm: vc4: dsi: Clocks should be running before reset
@@ -1,4 +1,4 @@
From 9cd8a5347a9aa06f60512c4ca4fe1c7591c7e1d9 Mon Sep 17 00:00:00 2001
From 0bbed592738a780abea49cd71806daa40d9ce602 Mon Sep 17 00:00:00 2001
From: Dave Stevenson <dave.stevenson@raspberrypi.com>
Date: Fri, 5 Apr 2024 17:51:55 +0100
Subject: [PATCH 36/50] drm/vc4: Ensure DSI is enabled for FIFO resets
@@ -1,4 +1,4 @@
From 7c91806a1637d2fc3c83b44cbd758741527b93bb Mon Sep 17 00:00:00 2001
From db28d17b76e911d9e592b07b38dcaa2990efd942 Mon Sep 17 00:00:00 2001
From: Dave Stevenson <dave.stevenson@raspberrypi.com>
Date: Thu, 26 May 2022 18:56:19 +0100
Subject: [PATCH 37/50] drm: vc4: Reset DSI AFE on disable
@@ -1,4 +1,4 @@
From 772bd1971a7c1fb2554cadfb111e8aabb974dd1a Mon Sep 17 00:00:00 2001
From a3da11f394aaa52ee4a3be1ea63b2439066b126a Mon Sep 17 00:00:00 2001
From: Dave Stevenson <dave.stevenson@raspberrypi.com>
Date: Wed, 20 Nov 2024 13:58:08 +0000
Subject: [PATCH 38/50] drm: vc4: dsi: Handle the different command FIFO widths
@@ -1,4 +1,4 @@
From 419f4339059576e35a8e4ebebd3677a8e3f6143f Mon Sep 17 00:00:00 2001
From 4f9b6e7917fb525ce1c8a1247602e67e0a90be39 Mon Sep 17 00:00:00 2001
From: Dave Stevenson <dave.stevenson@raspberrypi.com>
Date: Tue, 9 Jan 2024 17:37:00 +0000
Subject: [PATCH 39/50] drm/bridge: tc358762: Program the DPI mode into the
@@ -1,4 +1,4 @@
From 66d0d523c84e59a08a1aa1849aa0363bfa3a7641 Mon Sep 17 00:00:00 2001
From 24575bd2a0efeeae983c605786095fb2d49978cf Mon Sep 17 00:00:00 2001
From: Dave Stevenson <dave.stevenson@raspberrypi.com>
Date: Tue, 9 Jan 2024 18:44:49 +0000
Subject: [PATCH 40/50] drm/bridge: tc358762: revert move ops to enable
@@ -1,4 +1,4 @@
From e55233b40d6ed7e6643bb439780c96613c0b1a0c Mon Sep 17 00:00:00 2001
From bac7eb3cfe56c161944b77f9729f5f3fe5fd0d70 Mon Sep 17 00:00:00 2001
From: Mattias Walström <lazzer@gmail.com>
Date: Sat, 4 Apr 2026 18:04:19 +0200
Subject: [PATCH 41/50] drm/bridge: tc358762: Set pre_enabled on pre_enable to
@@ -1,4 +1,4 @@
From 6683a6a60276d5c39451eb7049247e043937bc2f Mon Sep 17 00:00:00 2001
From 6dcd3256ab1fcf224fc7bb41fb47b37f9f7cda16 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Sun, 5 Apr 2026 11:33:00 +0200
Subject: [PATCH 42/50] net/pcs: add standalone PCS registration infrastructure
@@ -1,4 +1,4 @@
From 07da654fcd16b2ae3a7a767ff89f87a3da94f22d Mon Sep 17 00:00:00 2001
From ab53ad238214316a1c6feab8f6d85c06832e7a47 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Mon, 6 Apr 2026 14:14:23 +0200
Subject: [PATCH 43/50] net/pcs: add MediaTek MT7988 USXGMII PCS driver
@@ -1,4 +1,4 @@
From f9cb160046a757abf5b081d6030011b54f3bc9b0 Mon Sep 17 00:00:00 2001
From 5c7ae0ba30f846779eca1be5a9f36e1239ff74d8 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Mon, 6 Apr 2026 14:15:43 +0200
Subject: [PATCH 44/50] net: ethernet: mediatek: add USXGMII support for MT7988
@@ -1,4 +1,4 @@
From 5cb31be412825eb8b95a79b89117587a8034d924 Mon Sep 17 00:00:00 2001
From 6f05fd435c115a8bee19c93c22cf31ca1ecc89be Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Mon, 6 Apr 2026 14:15:56 +0200
Subject: [PATCH 45/50] arm64: dts: mediatek: mt7988a: add USXGMII PCS nodes
@@ -1,4 +1,4 @@
From 58e5a335513c90e40ef4d6b0e0dff018d77dce84 Mon Sep 17 00:00:00 2001
From 413263b811c631ae2ef984583590fd2297ef648a Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Mon, 6 Apr 2026 14:16:11 +0200
Subject: [PATCH 46/50] arm64: dts: mediatek: bananapi-bpi-r4: enable SFP+
@@ -1,4 +1,4 @@
From 7528752150ebe1c29135d29d3a69b5c692771c18 Mon Sep 17 00:00:00 2001
From d23cac53e2dbce4ce7f37aa8dd98dc5528bc45e0 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Tue, 7 Apr 2026 07:34:52 +0200
Subject: [PATCH 47/50] net: phy: sfp: add OEM SFP-10G-T-I quirk
@@ -1,4 +1,4 @@
From 51077597c4f3e458d4feb0ea36cc4ecbef3d635a Mon Sep 17 00:00:00 2001
From 98bd974f7aedfa4a8181daa3f39867e480e097fa Mon Sep 17 00:00:00 2001
From: Tobias Waldekranz <tobias@waldekranz.com>
Date: Fri, 17 Apr 2026 09:13:04 +0000
Subject: [PATCH 48/50] net: dsa: mv88e6xxx: Trap PTP frames on timestamping
@@ -1,4 +1,4 @@
From e5e0572d67cc4be9db0a4d4f7d5c1b973b12da8e Mon Sep 17 00:00:00 2001
From a212af73720bb070c7b9a18e70985ea659ab0039 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Tue, 28 Apr 2026 15:30:01 +0200
Subject: [PATCH 49/50] wifi: mt76: mt7615: add MODULE_DEVICE_TABLE for mt7622
@@ -1,4 +1,4 @@
From 29cd680b8b3b2089bbcb67ee36117459bc11919d Mon Sep 17 00:00:00 2001
From 6c7aca4d3d77be389ff4d533a9f7eed095442aad Mon Sep 17 00:00:00 2001
From: Mattias Walström <lazzer@gmail.com>
Date: Wed, 22 Apr 2026 10:24:43 +0200
Subject: [PATCH 50/50] PCI: mediatek-gen3: Fix PERST# control timing during
+1 -1
View File
@@ -1,2 +1,2 @@
# Calculated with utils/kernel-refresh.sh
sha256 a83cd200e6646db52866b8309e9137b9e9048b613cbda10ced2b811aae125255 linux-6.18.37.tar.xz
sha256 fbab86c9f471c81075b280cca30bd85d790c060063a1245859b6344b07c9c44e linux-6.18.36.tar.xz
+13 -7
View File
@@ -2,20 +2,26 @@
# Generate ietf-system.yang hostname based on:
# 1. Command line argument + MAC suffix
# 2. Specific hostname from qemu_fw_cfg, from Qeneth
# 3. Default hostname + MAC suffix
#
# The MAC suffix is emitted as the %m format specifier, expanded by confd
# at apply time, so the actual MAC never ends up baked into a saved
# configuration (issue #1554).
# 3. /etc/hostname from squashfs + MAC suffix
#
set -e
gen()
{
base_mac=$(jq -r '.["mac-address"]' /run/system.json)
if [ -z "$base_mac" ] || [ "$base_mac" = "null" ]; then
base_mac=00:00:00:00:00:00
fi
echo "$1-$(echo $base_mac | tail -c 9 | tr ':' '-')"
}
if [ "$1" ]; then
sysname="$1-%m"
sysname=$(gen "$1")
elif [ -f "/sys/firmware/qemu_fw_cfg/by_name/opt/hostname/raw" ]; then
sysname="$(cat /sys/firmware/qemu_fw_cfg/by_name/opt/hostname/raw)"
else
sysname="%h-%m"
sysname=$(gen "$(cat /etc/hostname)")
fi
cat <<EOF
+10 -1
View File
@@ -439,7 +439,7 @@ static confd_dependency_t dep_radio_components(struct lyd_node **diff, struct ly
class = lyd_get_value(class_node);
}
if (!class || strcmp(class, "infix-hardware:wifi"))
if (strcmp(class, "infix-hardware:wifi"))
continue;
/* Find all interfaces that reference this radio */
@@ -674,6 +674,15 @@ static int change_cb(sr_session_ctx_t *session, uint32_t sub_id, const char *mod
return SR_ERR_SYS;
}
/*
Send sighup to yangerd to trigger a poll of polled values.
This will make sure that there is no stale data.
*/
if (systemf("initctl -b reload yangerd")) {
EMERG("Failed reloading yangerd");
return SR_ERR_SYS;
}
AUDIT("The new configuration has been applied.");
}
+5 -27
View File
@@ -237,7 +237,7 @@ static const char *wifi_find_higher_band_twin(struct lyd_node *config,
{
struct lyd_node *cifs, *cif;
if (!current_band || !current_ssid || strcmp(current_band, "2.4GHz"))
if (strcmp(current_band, "2.4GHz"))
return NULL;
cifs = lydx_get_descendant(config, "interfaces", "interface", NULL);
@@ -252,13 +252,13 @@ static const char *wifi_find_higher_band_twin(struct lyd_node *config,
if (!ap)
continue;
ssid = lydx_get_cattr(ap, "ssid");
if (!ssid || strcmp(ssid, current_ssid))
if (strcmp(ssid, current_ssid))
continue;
radio = lydx_get_cattr(wifi, "radio");
radio_node = lydx_get_xpathf(config,
"/hardware/component[name='%s']/wifi-radio", radio);
band = lydx_get_cattr(radio_node, "band");
if (band && (!strcmp(band, "5GHz") || !strcmp(band, "6GHz")))
if (!strcmp(band, "5GHz") || !strcmp(band, "6GHz"))
return lydx_get_cattr(cif, "name");
}
@@ -1379,32 +1379,8 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l
* all radio configs, so band edits and radio add/remove both apply.
*/
if (wifi_changed && event == SR_EV_DONE) {
struct lyd_node *cifs;
glob_t gl = { 0 };
size_t i;
/* Need to remove old hostapd config files, for radios not used anymore */
cifs = lydx_get_descendant(config, "interfaces", "interface", NULL);
if (glob("/etc/hostapd-*.conf", 0, NULL, &gl) == 0) {
for (i = 0; i < gl.gl_pathc; i++) {
char radio[64], **aps = NULL;
int n = 0, j;
if (sscanf(gl.gl_pathv[i], "/etc/hostapd-%63[^.].conf", radio) != 1)
continue;
wifi_find_radio_aps(cifs, radio, &aps, &n);
for (j = 0; j < n; j++)
free(aps[j]);
free(aps);
if (!n) {
unlink(gl.gl_pathv[i]);
erasef(HOSTAPD_CONF_NEXT, radio);
}
}
globfree(&gl);
}
gl = (glob_t){ 0 };
if (glob("/etc/hostapd-*.conf", 0, NULL, &gl) == 0 && gl.gl_pathc > 0) {
FILE *fp;
@@ -1413,6 +1389,8 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l
ERRNO("Could not open " HOSTAPD_SERVICE);
rc = SR_ERR_INTERNAL;
} else {
size_t i;
fprintf(fp, "# Generated by confd, do not edit.\n");
fprintf(fp, "service <!> name:hostapd \\\n");
fprintf(fp, "\t[2345] hostapd -P /run/hostapd.pid");
+1 -1
View File
@@ -237,7 +237,7 @@ static int netdag_gen_ethtool_autoneg(struct dagger *net, struct lyd_node *cif)
err = -EINVAL;
goto out;
}
if (n != 1 || !forced) {
if (n != 1) {
sr_session_set_error_message(net->session,
"%s: auto-negotiation/enable=false requires exactly one "
"advertised-pmd-types entry (have %d)", ifname, n);
+17 -8
View File
@@ -283,7 +283,7 @@ static int wifi_center_chan_160(int ch)
*/
static int wifi_chan_to_freq(int channel, const char *band)
{
if (band && !strcmp(band, "6GHz"))
if (!strcmp(band, "6GHz"))
return 5950 + channel * 5;
if (channel >= 1 && channel <= 13)
@@ -392,7 +392,7 @@ int wifi_gen_mesh(struct lyd_node *cif)
int center = wifi_center_chan_80(channel);
fprintf(wpa_supplicant, " ht40=1\n");
if (band && !strcmp(band, "6GHz"))
if (!strcmp(band, "6GHz"))
fprintf(wpa_supplicant, " he=1\n");
else
fprintf(wpa_supplicant, " vht=1\n");
@@ -403,7 +403,7 @@ int wifi_gen_mesh(struct lyd_node *cif)
int center = wifi_center_chan_160(channel);
fprintf(wpa_supplicant, " ht40=1\n");
if (band && !strcmp(band, "6GHz"))
if (!strcmp(band, "6GHz"))
fprintf(wpa_supplicant, " he=1\n");
else
fprintf(wpa_supplicant, " vht=1\n");
@@ -540,6 +540,7 @@ out:
*/
int wifi_del_iface(struct lyd_node *dif, struct dagger *net)
{
struct lyd_node *wifi;
const char *ifname;
FILE *iw;
@@ -552,14 +553,22 @@ int wifi_del_iface(struct lyd_node *dif, struct dagger *net)
}
fprintf(iw, "# Generated by Infix confd - WiFi Interface Deletion\n");
erasef(WPA_SUPPLICANT_CONF, ifname);
fprintf(iw, "initctl -bfq disable mesh@%s\n", ifname);
fprintf(iw, "initctl -bfq disable wifi@%s\n", ifname);
fprintf(iw, "ip link set %s down\n", ifname);
fprintf(iw, "iw dev %s disconnect 2>/dev/null\n", ifname);
fprintf(iw, "iw dev %s del 2>/dev/null || ip link del %s 2>/dev/null || true\n", ifname, ifname);
wifi = lydx_get_child(dif, "wifi");
if (wifi) {
int mode = wifi_get_mode(wifi);
if (mode == wifi_mesh) {
erasef(WPA_SUPPLICANT_CONF, ifname);
fprintf(iw, "initctl -bfq disable mesh@%s\n", ifname);
} else if (mode != wifi_ap) {
erasef(WPA_SUPPLICANT_CONF, ifname);
fprintf(iw, "initctl -bfq disable wifi@%s\n", ifname);
}
}
fclose(iw);
return SR_ERR_OK;
+1 -1
View File
@@ -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 yangerd.c yangerd.h
statd_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE
statd_CFLAGS = -W -Wall -Wextra
statd_CFLAGS += $(jansson_CFLAGS) $(libyang_CFLAGS) $(sysrepo_CFLAGS)
+55 -114
View File
@@ -21,23 +21,16 @@
#include <ctype.h>
#include <linux/if.h>
#include <sys/queue.h>
#include <sys/mman.h>
#include <srx/common.h>
#include <srx/helpers.h>
#include <srx/lyx.h>
#include <srx/systemv.h>
#include "shared.h"
#include "journal.h"
#include "avahi.h"
#include "yangerd.h"
/* New kernel feature, not in sys/mman.h yet */
#ifndef MFD_NOEXEC_SEAL
#define MFD_NOEXEC_SEAL 0x0008U
#endif
#define YANGER_BINPATH YANGER_DIR"/yanger"
#define XPATH_MAX PATH_MAX
#define XPATH_IFACE_BASE "/ietf-interfaces:interfaces"
#define XPATH_ROUTING_BASE "/ietf-routing:routing/control-plane-protocols/control-plane-protocol"
@@ -59,6 +52,7 @@ TAILQ_HEAD(sub_head, sub);
struct sub {
struct ev_io watcher;
sr_subscription_ctx_t *sr_sub;
char key[XPATH_MAX]; /* yangerd key, derived from the subscription xpath */
TAILQ_ENTRY(sub)
entries;
@@ -74,98 +68,60 @@ struct statd {
struct mdns_ctx mdns; /* mDNS neighbor monitor */
};
static int ly_add_yanger_data(const struct ly_ctx *ctx, struct lyd_node **parent,
char *yanger_args[])
static int ly_add_yangerd_data(const struct ly_ctx *ctx, struct lyd_node **parent,
const char *path)
{
FILE *stream;
char *json = NULL;
size_t len = 0;
int err;
int fd;
fd = memfd_create("yanger_tmpfile", MFD_CLOEXEC | MFD_NOEXEC_SEAL);
if (fd == -1) {
ERROR("Error, unable to create memfd");
return SR_ERR_SYS;
}
/* Wrap the file descriptor in a FILE stream for fwrite */
stream = fdopen(fd, "w+");
if (stream == NULL) {
ERROR("Error, unable to fdopen memfd");
close(fd);
return SR_ERR_SYS;
}
err = fsystemv(yanger_args, NULL, stream, NULL);
err = yangerd_query(path, &json, &len);
if (err) {
ERROR("Error, running yanger");
fclose(stream);
free(json);
ERROR("yangerd: query failed for %s", path);
return SR_ERR_SYS;
}
fflush(stream);
NOTE("yangerd: got %zu bytes JSON for %s", len, path);
if (lseek(fd, 0, SEEK_SET) == (off_t)-1) {
ERROR("Error, unable reset stream (seek)");
fclose(stream);
return SR_ERR_SYS;
}
err = lyd_parse_data_fd(ctx, fd, LYD_JSON, LYD_PARSE_ONLY, 0, parent);
err = lyd_parse_data_mem(ctx, json, LYD_JSON, LYD_PARSE_ONLY, 0, parent);
if (err)
ERROR("Error, parsing yanger data (%d): %s", err, ly_errmsg(ctx));
fclose(stream);
/* Note: fclose() already closes the underlying fd from fdopen() */
free(json);
return err;
}
static char *xpath_extract(const char *xpath, const char *key)
static const char *xpath_to_yangerd_path(const char *xpath, char *buf, size_t bufsz)
{
char *res = NULL;
const char *ptr;
const char *end;
const char *start, *slash;
size_t len;
/* (also checks if key exist) */
ptr = strstr(xpath, key);
if (!ptr)
return NULL;
ptr += strlen(key);
end = strchr(ptr, '\'');
if (!end) {
ERROR("Can't find end quote for %s (sanity check)", key);
return NULL;
if (!xpath || !*xpath || !strcmp(xpath, "*") || !strcmp(xpath, "/*")) {
buf[0] = '\0';
return buf;
}
if ((end - ptr) >= XPATH_MAX) {
ERROR("Value for %s is to long (sanity check)", key);
return NULL;
}
start = xpath;
if (*start == '/')
start++;
res = calloc((end - ptr) + 1, sizeof(char));
if (!res)
return NULL;
slash = strchr(start, '/');
len = slash ? (size_t)(slash - start) : strlen(start);
strncpy(res, ptr, end - ptr);
res[end - ptr] = '\0';
if (len >= bufsz)
len = bufsz - 1;
return res;
memcpy(buf, start, len);
buf[len] = '\0';
return buf;
}
static int sr_iface_cb(sr_session_ctx_t *session, uint32_t, const char *model,
static int sr_iface_cb(sr_session_ctx_t *session, uint32_t, const char *,
const char *, const char *xpath, uint32_t,
struct lyd_node **parent, __attribute__((unused)) void *priv)
{
char *yanger_args[5] = {
YANGER_BINPATH,
(char *)model,
NULL,
NULL,
NULL
};
char *ifname = NULL;
const struct ly_ctx *ctx;
sr_conn_ctx_t *con;
int err;
@@ -184,34 +140,25 @@ static int sr_iface_cb(sr_session_ctx_t *session, uint32_t, const char *model,
return SR_ERR_INTERNAL;
}
ifname = xpath_extract(xpath, "[name='");
if (ifname) {
yanger_args[2] = "-p";
yanger_args[3] = ifname;
}
err = ly_add_yanger_data(ctx, parent, yanger_args);
err = ly_add_yangerd_data(ctx, parent, "ietf-interfaces:interfaces");
if (err)
ERROR("Error adding interface yanger data");
ERROR("Error adding interface data (err %d)", err);
sr_release_context(con);
return SR_ERR_OK;
return err ? SR_ERR_INTERNAL : SR_ERR_OK;
}
static int sr_generic_cb(sr_session_ctx_t *session, uint32_t, const char *model,
static int sr_generic_cb(sr_session_ctx_t *session, uint32_t, const char *,
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,
(char *)model,
NULL
};
struct sub *sub = priv;
const struct ly_ctx *ctx;
sr_conn_ctx_t *con;
sr_error_t err;
DEBUG("Incoming generic query for xpath: %s", xpath);
DEBUG("Incoming generic query for xpath: %s -> key %s", xpath, sub->key);
con = sr_session_get_connection(session);
if (!con) {
@@ -225,9 +172,9 @@ static int sr_generic_cb(sr_session_ctx_t *session, uint32_t, const char *model,
return SR_ERR_INTERNAL;
}
err = ly_add_yanger_data(ctx, parent, yanger_args);
err = ly_add_yangerd_data(ctx, parent, sub->key);
if (err)
ERROR("Error adding yanger data");
ERROR("Error adding data for %s", sub->key);
sr_release_context(con);
@@ -238,11 +185,6 @@ static int sr_ospf_cb(sr_session_ctx_t *session, uint32_t, const char *,
const char *, const char *xpath, uint32_t,
struct lyd_node **parent, __attribute__((unused)) void *priv)
{
char *yanger_args[5] = {
YANGER_BINPATH,
"ietf-ospf",
NULL
};
const struct ly_ctx *ctx;
sr_conn_ctx_t *con;
sr_error_t err;
@@ -261,9 +203,9 @@ static int sr_ospf_cb(sr_session_ctx_t *session, uint32_t, const char *,
return SR_ERR_INTERNAL;
}
err = ly_add_yanger_data(ctx, parent, yanger_args);
err = ly_add_yangerd_data(ctx, parent, "ietf-routing:routing");
if (err)
ERROR("Error adding yanger data");
ERROR("Error adding OSPF data");
sr_release_context(con);
@@ -274,11 +216,6 @@ static int sr_rip_cb(sr_session_ctx_t *session, uint32_t, const char *,
const char *, const char *xpath, uint32_t,
struct lyd_node **parent, __attribute__((unused)) void *priv)
{
char *yanger_args[5] = {
YANGER_BINPATH,
"ietf-rip",
NULL
};
const struct ly_ctx *ctx;
sr_conn_ctx_t *con;
sr_error_t err;
@@ -297,9 +234,9 @@ static int sr_rip_cb(sr_session_ctx_t *session, uint32_t, const char *,
return SR_ERR_INTERNAL;
}
err = ly_add_yanger_data(ctx, parent, yanger_args);
err = ly_add_yangerd_data(ctx, parent, "ietf-routing:routing");
if (err)
ERROR("Error adding yanger data");
ERROR("Error adding RIP data");
sr_release_context(con);
@@ -310,11 +247,6 @@ static int sr_bfd_cb(sr_session_ctx_t *session, uint32_t, const char *,
const char *, const char *xpath, uint32_t,
struct lyd_node **parent, __attribute__((unused)) void *priv)
{
char *yanger_args[5] = {
YANGER_BINPATH,
"ietf-bfd-ip-sh",
NULL
};
const struct ly_ctx *ctx;
sr_conn_ctx_t *con;
sr_error_t err;
@@ -333,9 +265,9 @@ static int sr_bfd_cb(sr_session_ctx_t *session, uint32_t, const char *,
return SR_ERR_INTERNAL;
}
err = ly_add_yanger_data(ctx, parent, yanger_args);
err = ly_add_yangerd_data(ctx, parent, "ietf-routing:routing");
if (err)
ERROR("Error adding yanger data");
ERROR("Error adding BFD data");
sr_release_context(con);
@@ -379,7 +311,16 @@ static int subscribe(struct statd *statd, char *model, char *xpath,
sub = malloc(sizeof(struct sub));
memset(sub, 0, sizeof(struct sub));
DEBUG("Subscribe to events for \"%s\"", xpath);
/*
* Derive the yangerd key from the (static) subscription xpath here,
* once. The generic callback must NOT derive it from the runtime
* request xpath sysrepo hands it -- that is unreliable and yields a
* bare "system-state" for /ietf-system:system-state, which yangerd
* (keyed "ietf-system:system-state") cannot match.
*/
xpath_to_yangerd_path(xpath, sub->key, sizeof(sub->key));
DEBUG("Subscribe to events for \"%s\" (key \"%s\")", xpath, sub->key);
err = sr_oper_get_subscribe(statd->sr_ses, model, xpath, cb, sub,
SR_SUBSCR_DEFAULT | SR_SUBSCR_NO_THREAD | SR_SUBSCR_DONE_ONLY,
&sub->sr_sub);
+246
View File
@@ -0,0 +1,246 @@
/* SPDX-License-Identifier: BSD-3-Clause */
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <jansson.h>
#include <srx/common.h>
#include "yangerd.h"
static const char *yangerd_socket_path(void)
{
const char *env;
env = getenv("YANGERD_SOCKET");
if (env && *env)
return env;
return YANGERD_SOCKET_DEFAULT;
}
static int yangerd_connect(void)
{
struct sockaddr_un addr = { .sun_family = AF_UNIX };
struct timeval tv = { .tv_sec = YANGERD_TIMEOUT_SEC };
const char *path;
int fd;
path = yangerd_socket_path();
if (strlen(path) >= sizeof(addr.sun_path)) {
ERROR("yangerd socket path too long: %s", path);
return -1;
}
strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1);
fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) {
ERROR("yangerd: socket(): %s", strerror(errno));
return -1;
}
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
DEBUG("yangerd: connect(%s): %s", path, strerror(errno));
close(fd);
return -1;
}
return fd;
}
static int yangerd_write_all(int fd, const void *buf, size_t len)
{
const unsigned char *p = buf;
while (len > 0) {
ssize_t n = write(fd, p, len);
if (n < 0) {
if (errno == EINTR)
continue;
return -1;
}
p += n;
len -= n;
}
return 0;
}
static int yangerd_read_all(int fd, void *buf, size_t len)
{
unsigned char *p = buf;
while (len > 0) {
ssize_t n = read(fd, p, len);
if (n < 0) {
if (errno == EINTR)
continue;
return -1;
}
if (n == 0) {
errno = ECONNRESET;
return -1;
}
p += n;
len -= n;
}
return 0;
}
static int yangerd_send_request(int fd, const char *path)
{
json_t *req;
char *json_str;
size_t json_len;
unsigned char hdr[5];
int rc = -1;
req = json_pack("{s:s, s:s}", "method", "get", "path", path);
if (!req)
return -1;
json_str = json_dumps(req, JSON_COMPACT);
json_decref(req);
if (!json_str)
return -1;
json_len = strlen(json_str);
if (json_len > YANGERD_MAX_PAYLOAD) {
free(json_str);
return -1;
}
hdr[0] = YANGERD_PROTO_VERSION;
hdr[1] = (json_len >> 24) & 0xff;
hdr[2] = (json_len >> 16) & 0xff;
hdr[3] = (json_len >> 8) & 0xff;
hdr[4] = (json_len >> 0) & 0xff;
if (yangerd_write_all(fd, hdr, sizeof(hdr)) < 0)
goto out;
if (yangerd_write_all(fd, json_str, json_len) < 0)
goto out;
rc = 0;
out:
free(json_str);
return rc;
}
static int yangerd_recv_response(int fd, char **buf, size_t *len)
{
unsigned char hdr[5];
uint32_t payload_len;
json_error_t jerr;
json_t *resp;
json_t *status;
json_t *data;
char *body;
char *data_str;
*buf = NULL;
*len = 0;
if (yangerd_read_all(fd, hdr, sizeof(hdr)) < 0)
return -1;
if (hdr[0] != YANGERD_PROTO_VERSION) {
ERROR("yangerd: protocol version mismatch: got %u, want %u",
hdr[0], YANGERD_PROTO_VERSION);
return -1;
}
payload_len = ((uint32_t)hdr[1] << 24) |
((uint32_t)hdr[2] << 16) |
((uint32_t)hdr[3] << 8) |
((uint32_t)hdr[4]);
if (payload_len > YANGERD_MAX_PAYLOAD) {
ERROR("yangerd: payload too large: %u", payload_len);
return -1;
}
body = malloc(payload_len + 1);
if (!body)
return -1;
if (yangerd_read_all(fd, body, payload_len) < 0) {
free(body);
return -1;
}
body[payload_len] = '\0';
resp = json_loads(body, 0, &jerr);
free(body);
if (!resp) {
ERROR("yangerd: invalid response JSON: %s", jerr.text);
return -1;
}
status = json_object_get(resp, "status");
if (!json_is_string(status) || strcmp(json_string_value(status), "ok")) {
json_t *msg = json_object_get(resp, "message");
ERROR("yangerd: request failed: %s",
json_is_string(msg) ? json_string_value(msg) : "unknown");
json_decref(resp);
return -1;
}
data = json_object_get(resp, "data");
if (!data || json_is_null(data)) {
json_decref(resp);
*buf = strdup("{}");
*len = 2;
return 0;
}
data_str = json_dumps(data, JSON_COMPACT);
json_decref(resp);
if (!data_str)
return -1;
*buf = data_str;
*len = strlen(data_str);
return 0;
}
int yangerd_query(const char *path, char **buf, size_t *len)
{
int fd;
int rc;
*buf = NULL;
*len = 0;
fd = yangerd_connect();
if (fd < 0)
return -1;
if (yangerd_send_request(fd, path) < 0) {
ERROR("yangerd: failed sending request for %s", path);
close(fd);
return -1;
}
rc = yangerd_recv_response(fd, buf, len);
if (rc < 0)
ERROR("yangerd: failed reading response for %s", path);
close(fd);
return rc;
}
+28
View File
@@ -0,0 +1,28 @@
/* SPDX-License-Identifier: BSD-3-Clause */
#ifndef STATD_YANGERD_H_
#define STATD_YANGERD_H_
#include <stddef.h>
#define YANGERD_SOCKET_DEFAULT "/run/yangerd.sock"
#define YANGERD_TIMEOUT_SEC 5
#define YANGERD_MAX_PAYLOAD (4 << 20) /* 4 MiB, matches Go side */
#define YANGERD_PROTO_VERSION 0x01
/**
* yangerd_query() - Query yangerd daemon for operational YANG data
* @path: YANG model path, e.g. "ietf-interfaces:interfaces"
* @buf: Output pointer to malloc'd JSON string (caller must free)
* @len: Output length of JSON data
*
* Connects to the yangerd Unix socket, sends a "get" request for @path,
* reads the framed response, and extracts the "data" field as a JSON
* string. The socket path defaults to %YANGERD_SOCKET_DEFAULT but can
* be overridden with the YANGERD_SOCKET environment variable.
*
* Return: 0 on success, -1 on error (buf is set to NULL).
*/
int yangerd_query(const char *path, char **buf, size_t *len);
#endif
+3
View File
@@ -0,0 +1,3 @@
# Build artifacts (root-level binaries only; do not match cmd/ source dirs)
/yangerd
/yangerctl
+27
View File
@@ -0,0 +1,27 @@
Copyright (c) 2025 The KernelKit Authors
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of copyright holders nor the names of
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/ipc"
)
const defaultSocket = "/run/yangerd.sock"
const defaultTimeout = 5 * time.Second
func main() {
socket := defaultSocket
timeout := defaultTimeout
args := os.Args[1:]
for len(args) > 0 && len(args[0]) > 0 && args[0][0] == '-' {
switch args[0] {
case "--socket":
if len(args) < 2 {
die("--socket requires an argument")
}
socket = args[1]
args = args[2:]
case "--timeout":
if len(args) < 2 {
die("--timeout requires an argument")
}
d, err := time.ParseDuration(args[1])
if err != nil {
die("invalid duration: %v", err)
}
timeout = d
args = args[2:]
default:
die("unknown flag: %s", args[0])
}
}
if len(args) == 0 {
usage()
}
client := ipc.NewClient(socket, timeout)
switch args[0] {
case "get":
if len(args) < 2 {
die("get requires a path argument")
}
resp, err := client.Get(args[1])
if err != nil {
die("get: %v", err)
}
printResponse(resp)
case "dump":
resp, err := client.Get("/")
if err != nil {
die("dump: %v", err)
}
printResponse(resp)
case "health":
resp, err := client.Health()
if err != nil {
die("health: %v", err)
}
printResponse(resp)
default:
die("unknown command: %s", args[0])
}
}
func printResponse(resp *ipc.Response) {
if resp.Code == 503 {
fmt.Fprintf(os.Stderr, "yangerd is starting up\n")
os.Exit(3)
}
if resp.Status == "error" {
fmt.Fprintf(os.Stderr, "error %d: %s\n", resp.Code, resp.Message)
if resp.Code == 404 {
os.Exit(2)
}
os.Exit(1)
}
var out []byte
if resp.Data != nil {
out, _ = json.MarshalIndent(json.RawMessage(resp.Data), "", " ")
} else {
out, _ = json.MarshalIndent(resp, "", " ")
}
fmt.Println(string(out))
}
func usage() {
fmt.Fprintf(os.Stderr, "Usage: yangerctl [--socket path] [--timeout dur] <command> [args]\n\n")
fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " get <path> Query a YANG subtree\n")
fmt.Fprintf(os.Stderr, " dump Dump entire tree\n")
fmt.Fprintf(os.Stderr, " health Show daemon health\n")
os.Exit(1)
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "yangerctl: "+format+"\n", args...)
os.Exit(1)
}
+434
View File
@@ -0,0 +1,434 @@
package main
import (
"context"
"encoding/json"
"log"
"log/slog"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/bridgebatch"
"github.com/kernelkit/infix/src/yangerd/internal/collector"
"github.com/kernelkit/infix/src/yangerd/internal/config"
"github.com/kernelkit/infix/src/yangerd/internal/containermonitor"
"github.com/kernelkit/infix/src/yangerd/internal/dbusmonitor"
"github.com/kernelkit/infix/src/yangerd/internal/ethmonitor"
"github.com/kernelkit/infix/src/yangerd/internal/frrvty"
"github.com/kernelkit/infix/src/yangerd/internal/fswatcher"
"github.com/kernelkit/infix/src/yangerd/internal/ipbatch"
"github.com/kernelkit/infix/src/yangerd/internal/ipc"
"github.com/kernelkit/infix/src/yangerd/internal/iwmonitor"
"github.com/kernelkit/infix/src/yangerd/internal/lldpmonitor"
"github.com/kernelkit/infix/src/yangerd/internal/monitor"
"github.com/kernelkit/infix/src/yangerd/internal/sysreaders"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
"github.com/kernelkit/infix/src/yangerd/internal/wgquery"
"github.com/kernelkit/infix/src/yangerd/internal/zapiwatcher"
)
// osFileChecker implements iface.FileChecker using the real filesystem.
type osFileChecker struct{}
func (osFileChecker) Exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func (osFileChecker) ReadFile(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(b), nil
}
func main() {
cfg := config.Load()
log.SetFlags(0)
t := tree.New()
ready := &atomic.Bool{}
srv := ipc.NewServer(t, ready)
if err := srv.Listen(cfg.Socket); err != nil {
log.Fatalf("listen %s: %v", cfg.Socket, err)
}
defer os.Remove(cfg.Socket)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
cmd := collector.ExecRunner{}
fs := collector.OSFileReader{}
collectors := []collector.Collector{
collector.NewSystemCollector(cmd, fs, cfg.PollSystem),
collector.NewRoutingCollector(cmd, cfg.PollRouting),
collector.NewNTPCollector(cmd, cfg.PollNTP),
collector.NewHardwareCollector(cmd, fs, cfg.PollHardware, cfg.EnableWifi, cfg.EnableGPS),
}
pokeCh := make(chan struct{}, len(collectors))
collector.RunAll(ctx, &wg, t, collectors, pokeCh)
inst := collector.DBusInstaller{}
t.RegisterProvider("ietf-system:system-state", func() json.RawMessage {
live := collector.LiveSystemState(fs)
installerOverlay := collector.MergeInstaller(t.GetCached("ietf-system:system-state"), inst)
if installerOverlay == nil {
return live
}
var base map[string]json.RawMessage
if json.Unmarshal(live, &base) != nil {
return live
}
var overlay map[string]json.RawMessage
if json.Unmarshal(installerOverlay, &overlay) != nil {
return live
}
for k, v := range overlay {
base[k] = v
}
merged, err := json.Marshal(base)
if err != nil {
return live
}
return merged
})
if data := collector.BootPlatform(fs); data != nil {
t.Merge("ietf-system:system-state", data)
}
if data := collector.BootSoftware(ctx, cmd); data != nil {
t.Merge("ietf-system:system-state", data)
}
slogLog := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slogLevel(cfg.LogLevel)}))
linkBatch, err := ipbatch.New(ctx, slogLog, ipbatch.WithStats(), ipbatch.WithDetails())
if err != nil {
log.Fatalf("start link batch: %v", err)
}
defer linkBatch.Close()
addrBatch, err := ipbatch.New(ctx, slogLog, ipbatch.WithDetails())
if err != nil {
log.Fatalf("start addr batch: %v", err)
}
defer addrBatch.Close()
neighBatch, err := ipbatch.New(ctx, slogLog)
if err != nil {
log.Fatalf("start neigh batch: %v", err)
}
defer neighBatch.Close()
brBatch, err := bridgebatch.New(ctx, slogLog)
if err != nil {
log.Fatalf("start bridge batch: %v", err)
}
defer brBatch.Close()
nlmon := monitor.New(linkBatch, addrBatch, neighBatch, brBatch, t, osFileChecker{}, slogLog)
ethMon, err := ethmonitor.New(slogLog, cmd)
if err != nil {
slogLog.Warn("ethmonitor unavailable, continuing without it", "err", err)
} else {
ethMon.SetOnUpdate(nlmon.SetEthernetData)
nlmon.SetEthRefresh(ethMon.RefreshInterface)
wg.Add(1)
go func() {
defer wg.Done()
if err := ethMon.Run(ctx); err != nil && ctx.Err() == nil {
slogLog.Error("ethmonitor exited", "err", err)
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
<-nlmon.WaitReady()
for {
links := nlmon.Links()
for ifname, data := range wgquery.Query(links) {
nlmon.SetWireguardData(ifname, data)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(cfg.PollSTP)
defer ticker.Stop()
<-nlmon.WaitReady()
for {
nlmon.RefreshSTP()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
for {
if err := nlmon.Run(ctx); err != nil {
if ctx.Err() != nil {
return
}
slogLog.Error("nlmonitor exited, restarting in 1s", "err", err)
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
} else {
return
}
}
}()
if cfg.EnableWifi {
iwmon := iwmonitor.New(slogLog)
iwmon.SetOnUpdate(nlmon.SetWifiData)
iwmon.SetOnPhyChange(func() {
select {
case pokeCh <- struct{}{}:
default:
}
})
wg.Add(1)
go func() {
defer wg.Done()
if err := iwmon.Run(ctx); err != nil && ctx.Err() == nil {
slogLog.Error("iwmonitor exited", "err", err)
}
}()
}
if cfg.EnableLLDP {
lldpmon := lldpmonitor.New(t, slogLog)
wg.Add(1)
go func() {
defer wg.Done()
if err := lldpmon.Run(ctx); err != nil && ctx.Err() == nil {
slogLog.Error("lldpmonitor exited", "err", err)
}
}()
}
if cfg.EnableContainers {
ctrmon := containermonitor.New(t, cmd, fs, slogLog)
wg.Add(1)
go func() {
defer wg.Done()
if err := ctrmon.Run(ctx); err != nil && ctx.Err() == nil {
slogLog.Error("containermonitor exited", "err", err)
}
}()
}
zapi := zapiwatcher.New(t, frrvty.New(""), slogLog)
wg.Add(1)
go func() {
defer wg.Done()
if err := zapi.Run(ctx); err != nil && ctx.Err() == nil {
slogLog.Error("zapiwatcher exited", "err", err)
}
}()
if cfg.EnableDHCP || cfg.EnableFirewall {
dbusMon := dbusmonitor.New(t, slogLog)
wg.Add(1)
go func() {
defer wg.Done()
if err := dbusMon.Run(ctx); err != nil && ctx.Err() == nil {
slogLog.Error("dbusmonitor exited", "err", err)
}
}()
}
fsw, err := fswatcher.New(t, slogLog)
if err != nil {
log.Fatalf("start fswatcher: %v", err)
}
fwdAgg := sysreaders.NewForwardingAggregator()
forwardingPaths := []string{
"/proc/sys/net/ipv4/conf/*/forwarding",
"/proc/sys/net/ipv6/conf/*/forwarding",
}
for _, pattern := range forwardingPaths {
matches, globErr := filepath.Glob(pattern)
if globErr != nil {
slogLog.Warn("fswatcher glob failed", "pattern", pattern, "err", globErr)
continue
}
for _, path := range matches {
if err := fsw.Watch(path, fswatcher.WatchHandler{
TreeKey: routingTreeKey,
ReadFunc: fwdAgg.HandleForwardingChange,
Debounce: 100 * time.Millisecond,
UseMerge: true,
}); err != nil {
slogLog.Warn("fswatcher watch failed", "path", path, "err", err)
}
}
}
if err := fsw.Watch("/etc/hostname", fswatcher.WatchHandler{
TreeKey: "ietf-system:system",
ReadFunc: sysreaders.ReadHostname,
Debounce: 200 * time.Millisecond,
UseMerge: true,
}); err != nil {
slogLog.Warn("fswatcher watch failed", "path", "/etc/hostname", "err", err)
}
if err := fsw.WatchSymlink("/etc/localtime", fswatcher.WatchHandler{
TreeKey: "ietf-system:system",
ReadFunc: sysreaders.ReadTimezone,
Debounce: 200 * time.Millisecond,
UseMerge: true,
}); err != nil {
slogLog.Warn("fswatcher watch failed", "path", "/etc/localtime", "err", err)
}
usersHandler := fswatcher.WatchHandler{
TreeKey: "ietf-system:system",
ReadFunc: sysreaders.ReadUsers,
Debounce: 200 * time.Millisecond,
UseMerge: true,
}
if err := fsw.Watch("/etc/shadow", usersHandler); err != nil {
slogLog.Warn("fswatcher watch failed", "path", "/etc/shadow", "err", err)
}
if err := fsw.WatchDir(sysreaders.SSHDKeysDir, usersHandler); err != nil {
slogLog.Warn("fswatcher watch failed", "path", sysreaders.SSHDKeysDir, "err", err)
}
bootOrderHandler := fswatcher.WatchHandler{
TreeKey: "ietf-system:system-state",
ReadFunc: makeBootOrderReader(t, cmd),
Debounce: 200 * time.Millisecond,
UseMerge: true,
}
// Watch the parent directory, not the file: fw_setenv (U-Boot) and
// grub-editenv may rewrite the env via a temp file + rename, which
// gives it a new inode that a direct file watch never sees. Watching
// the directory catches the Create/Rename (and still catches in-place
// writes), so a boot-order change after a RAUC install is reflected
// without waiting for a reboot.
for _, path := range []string{"/mnt/aux/grub/grubenv", "/mnt/aux/uboot.env"} {
if err := fsw.WatchSymlink(path, bootOrderHandler); err != nil {
slogLog.Debug("fswatcher boot-order watch skipped", "path", path, "err", err)
}
}
dnsHandler := fswatcher.WatchHandler{
TreeKey: "ietf-system:system-state",
ReadFunc: sysreaders.ReadDNSResolver,
Debounce: 200 * time.Millisecond,
UseMerge: true,
}
for _, path := range []string{"/etc/resolv.conf.head", "/var/lib/misc/resolv.conf"} {
if err := fsw.WatchSymlink(path, dnsHandler); err != nil {
slogLog.Warn("fswatcher dns watch failed", "path", path, "err", err)
}
}
// Container operational data is handled by containermonitor (a
// `podman events` stream), not the fswatcher.
fsw.InitialRead()
wg.Add(1)
go func() {
defer wg.Done()
if err := fsw.Run(ctx); err != nil && ctx.Err() == nil {
slogLog.Error("fswatcher exited", "err", err)
}
}()
go func() {
<-nlmon.WaitReady()
ready.Store(true)
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP)
go func() {
for sig := range sigCh {
if sig == syscall.SIGHUP {
log.Printf("SIGHUP: triggering immediate re-poll")
for range len(collectors) {
pokeCh <- struct{}{}
}
continue
}
cancel()
return
}
}()
if err := srv.Serve(ctx); err != nil {
log.Fatalf("serve: %v", err)
}
wg.Wait()
}
func slogLevel(s string) slog.Level {
switch strings.ToLower(s) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
const routingTreeKey = "ietf-routing:routing"
func makeBootOrderReader(t *tree.Tree, cmd collector.CommandRunner) func(string) (json.RawMessage, error) {
return func(_ string) (json.RawMessage, error) {
bootOrder := collector.ReadBootOrder(context.TODO(), cmd)
raw := t.Get("ietf-system:system-state")
var state map[string]interface{}
if raw != nil {
json.Unmarshal(raw, &state)
}
if state == nil {
state = make(map[string]interface{})
}
sw, _ := state["infix-system:software"].(map[string]interface{})
if sw == nil {
sw = make(map[string]interface{})
}
if bootOrder != nil {
sw["boot-order"] = bootOrder
} else {
delete(sw, "boot-order")
}
return json.Marshal(map[string]interface{}{"infix-system:software": sw})
}
}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
module github.com/kernelkit/infix/src/yangerd
go 1.23.0
require (
github.com/fsnotify/fsnotify v1.9.0
github.com/godbus/dbus/v5 v5.2.2
github.com/mdlayher/ethtool v0.4.1
github.com/mdlayher/genetlink v1.3.2
github.com/mdlayher/netlink v1.8.0
github.com/vishvananda/netlink v1.3.1
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
)
require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
)
+34
View File
@@ -0,0 +1,34 @@
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/mdlayher/ethtool v0.4.1 h1:BSUOGbnNNuPfGRFlKwtONPLJAQiLw8JdgS7E+SuN3/M=
github.com/mdlayher/ethtool v0.4.1/go.mod h1:9XP/78xTcyrfP8HvM0pI5ETaGHAuxrHRs+xkst29NlY=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
+51
View File
@@ -0,0 +1,51 @@
// Package backoff provides exponential backoff retry logic with
// context-aware sleep, shared across reactive monitors.
package backoff
import (
"context"
"math"
"time"
)
// Backoff implements exponential backoff with a configurable initial
// delay, maximum delay, and growth factor.
type Backoff struct {
Initial time.Duration
Max time.Duration
Factor float64
}
// Default returns a Backoff with the standard yangerd parameters:
// 100ms initial, 30s max, factor 2.
func Default() *Backoff {
return &Backoff{
Initial: 100 * time.Millisecond,
Max: 30 * time.Second,
Factor: 2.0,
}
}
// Next returns the next delay value after current. If current is
// zero, Initial is returned.
func (b *Backoff) Next(current time.Duration) time.Duration {
if current <= 0 {
return b.Initial
}
next := time.Duration(math.Min(float64(current)*b.Factor, float64(b.Max)))
if next <= 0 {
return b.Initial
}
return next
}
// Sleep waits for duration d or until ctx is cancelled, whichever
// comes first. Returns ctx.Err() if the context was cancelled.
func Sleep(ctx context.Context, d time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(d):
return nil
}
}
@@ -0,0 +1,223 @@
// Package bridgebatch manages a persistent `bridge -json -batch -`
// subprocess for querying bridge FDB, VLAN, MDB, and STP state.
// Identical design to ipbatch: mutex-serialized queries, dead/alive
// state management, and exponential backoff restart.
//
// Like ipbatch, `bridge -batch -` produces NO stdout for commands
// that fail. Query uses a read timeout to detect this and kills
// the subprocess so restartLoop can recover.
package bridgebatch
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
"os/exec"
"sync"
"sync/atomic"
"time"
)
// ErrBatchDead is returned by Query when the subprocess is not running.
var ErrBatchDead = errors.New("bridge batch process is dead")
const (
canaryCommand = "vlan show"
queryTimeout = 5 * time.Second
reconnectInitial = 100 * time.Millisecond
reconnectMax = 30 * time.Second
reconnectFactor = 2.0
)
// BridgeBatch wraps a persistent `bridge -json -batch -` subprocess.
type BridgeBatch struct {
cmd *exec.Cmd
stdin io.WriteCloser
lines chan []byte
stderr io.ReadCloser
mu sync.Mutex
alive atomic.Bool
log *slog.Logger
ctx context.Context
cancel context.CancelFunc
}
// New spawns the bridge batch subprocess.
func New(ctx context.Context, log *slog.Logger) (*BridgeBatch, error) {
ctx, cancel := context.WithCancel(ctx)
b := &BridgeBatch{
log: log,
ctx: ctx,
cancel: cancel,
}
if err := b.start(); err != nil {
cancel()
return nil, err
}
go b.restartLoop()
return b, nil
}
func (b *BridgeBatch) start() error {
cmd := exec.CommandContext(b.ctx, "bridge", "-json", "-batch", "-")
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start bridge batch: %w", err)
}
b.mu.Lock()
b.cmd = cmd
b.stdin = stdin
b.lines = make(chan []byte, 8)
b.stderr = stderr
b.alive.Store(true)
b.mu.Unlock()
go b.readLines(stdout)
go b.drainStderr()
return nil
}
func (b *BridgeBatch) readLines(r io.Reader) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 4*1024*1024), 4*1024*1024)
for scanner.Scan() {
line := make([]byte, len(scanner.Bytes()))
copy(line, scanner.Bytes())
b.lines <- line
}
b.alive.Store(false)
}
// Query sends a command to the bridge batch process and returns the
// JSON response.
func (b *BridgeBatch) Query(command string) (json.RawMessage, error) {
if !b.alive.Load() {
return nil, ErrBatchDead
}
b.mu.Lock()
defer b.mu.Unlock()
if !b.alive.Load() {
return nil, ErrBatchDead
}
if _, err := fmt.Fprintf(b.stdin, "%s\n", command); err != nil {
b.alive.Store(false)
return nil, fmt.Errorf("write command: %w", err)
}
select {
case line, ok := <-b.lines:
if !ok {
b.alive.Store(false)
return nil, fmt.Errorf("bridge batch process exited")
}
return json.RawMessage(line), nil
case <-time.After(queryTimeout):
b.log.Warn("bridge batch query timeout, killing subprocess", "cmd", command)
b.alive.Store(false)
if b.cmd != nil && b.cmd.Process != nil {
b.cmd.Process.Kill()
}
return nil, fmt.Errorf("timeout waiting for response to: %s", command)
}
}
// Close terminates the subprocess and cancels the restart loop.
func (b *BridgeBatch) Close() {
b.cancel()
b.mu.Lock()
if b.stdin != nil {
b.stdin.Close()
}
if b.cmd != nil && b.cmd.Process != nil {
b.cmd.Process.Kill()
}
b.alive.Store(false)
b.mu.Unlock()
}
// Status returns "running" or "restarting".
func (b *BridgeBatch) Status() string {
if b.alive.Load() {
return "running"
}
return "restarting"
}
func (b *BridgeBatch) drainStderr() {
scanner := bufio.NewScanner(b.stderr)
for scanner.Scan() {
b.log.Warn("bridge batch stderr", "line", scanner.Text())
}
}
func (b *BridgeBatch) restartLoop() {
delay := reconnectInitial
for {
select {
case <-b.ctx.Done():
return
default:
}
if b.alive.Load() {
select {
case <-b.ctx.Done():
return
case <-time.After(200 * time.Millisecond):
continue
}
}
b.log.Info("bridge batch: subprocess died, restarting", "delay", delay)
select {
case <-b.ctx.Done():
return
case <-time.After(delay):
}
b.mu.Lock()
if b.cmd != nil && b.cmd.Process != nil {
b.cmd.Process.Kill()
b.cmd.Wait()
}
b.mu.Unlock()
if err := b.start(); err != nil {
b.log.Warn("bridge batch: restart failed", "err", err)
delay = time.Duration(math.Min(
float64(delay)*reconnectFactor,
float64(reconnectMax)))
continue
}
if _, err := b.Query(canaryCommand); err != nil {
b.log.Warn("bridge batch: canary query failed", "err", err)
b.alive.Store(false)
delay = time.Duration(math.Min(
float64(delay)*reconnectFactor,
float64(reconnectMax)))
continue
}
b.log.Info("bridge batch: restarted successfully")
delay = reconnectInitial
}
}
+170
View File
@@ -0,0 +1,170 @@
package collector
import (
"context"
"encoding/json"
"log"
"strconv"
"strings"
)
func BootPlatform(fs FileReader) json.RawMessage {
data, err := fs.ReadFile("/etc/os-release")
if err != nil {
log.Printf("boot: os-release: %v", err)
return nil
}
platform := make(map[string]interface{})
for _, line := range strings.Split(string(data), "\n") {
idx := strings.IndexByte(line, '=')
if idx < 0 {
continue
}
key := line[:idx]
val := strings.Trim(line[idx+1:], "\"")
if mapped, ok := platformKeyMap[key]; ok {
platform[mapped] = val
}
}
result, _ := json.Marshal(map[string]interface{}{"platform": platform})
return result
}
func BootSoftware(ctx context.Context, cmd CommandRunner) json.RawMessage {
software := make(map[string]interface{})
raucOut, err := cmd.Run(ctx, "rauc", "status", "--detailed", "--output-format=json")
if err == nil {
var raucData map[string]interface{}
if json.Unmarshal(raucOut, &raucData) == nil {
if v, ok := raucData["compatible"]; ok {
software["compatible"] = v
}
if v, ok := raucData["variant"]; ok {
software["variant"] = v
}
if v, ok := raucData["booted"]; ok {
software["booted"] = v
}
bootSoftwareSlots(software, raucData)
}
}
bootOrder := ReadBootOrder(ctx, cmd)
if bootOrder != nil {
software["boot-order"] = bootOrder
}
result, _ := json.Marshal(map[string]interface{}{"infix-system:software": software})
return result
}
func ReadBootOrder(ctx context.Context, cmd CommandRunner) []string {
out, err := cmd.Run(ctx, "fw_printenv", "BOOT_ORDER")
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, "BOOT_ORDER") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
return strings.Fields(parts[1])
}
}
}
}
out, err = cmd.Run(ctx, "grub-editenv", "/mnt/aux/grub/grubenv", "list")
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, "ORDER") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
return strings.Fields(strings.TrimSpace(parts[1]))
}
}
}
}
return nil
}
func bootSoftwareSlots(software map[string]interface{}, raucData map[string]interface{}) {
slotsRaw, ok := raucData["slots"]
if !ok {
return
}
slotsArr, ok := slotsRaw.([]interface{})
if !ok {
return
}
var slots []interface{}
for _, slotItem := range slotsArr {
slotMap, ok := slotItem.(map[string]interface{})
if !ok {
continue
}
for name, valRaw := range slotMap {
val, ok := valRaw.(map[string]interface{})
if !ok {
continue
}
s := map[string]interface{}{
"name": name,
"bootname": val["bootname"],
"class": val["class"],
"state": val["state"],
}
slotStatus, _ := val["slot_status"].(map[string]interface{})
if slotStatus == nil {
slots = append(slots, s)
continue
}
bundle := make(map[string]interface{})
if b, ok := slotStatus["bundle"].(map[string]interface{}); ok {
if v := b["compatible"]; v != nil {
bundle["compatible"] = v
}
if v := b["version"]; v != nil {
bundle["version"] = v
}
}
s["bundle"] = bundle
if ck, ok := slotStatus["checksum"].(map[string]interface{}); ok {
if v := ck["size"]; v != nil {
s["size"] = strconv.FormatInt(int64(toInt(v)), 10)
}
if v := ck["sha256"]; v != nil {
s["sha256"] = v
}
}
installed := make(map[string]interface{})
if inst, ok := slotStatus["installed"].(map[string]interface{}); ok {
if v := inst["timestamp"]; v != nil {
installed["datetime"] = v
}
if v := inst["count"]; v != nil {
installed["count"] = toInt(v)
}
}
s["installed"] = installed
activated := make(map[string]interface{})
if act, ok := slotStatus["activated"].(map[string]interface{}); ok {
if v := act["timestamp"]; v != nil {
activated["datetime"] = v
}
if v := act["count"]; v != nil {
activated["count"] = toInt(v)
}
}
s["activated"] = activated
slots = append(slots, s)
}
}
software["slot"] = slots
}
+236
View File
@@ -0,0 +1,236 @@
package collector
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/kernelkit/infix/src/yangerd/internal/testutil"
)
const (
testOSRelease = `NAME="Infix"
VERSION_ID="25.01.0"
BUILD_ID="v25.01.0"
ARCHITECTURE="x86_64"
HOME_URL="https://kernelkit.github.io"
`
testRaucStatus = `{
"compatible": "Infix x86_64",
"variant": "",
"booted": "rootfs.0",
"slots": [
{
"rootfs.0": {
"bootname": "A",
"class": "rootfs",
"state": "booted",
"slot_status": {
"bundle": {
"compatible": "Infix x86_64",
"version": "25.01.0"
},
"checksum": {
"sha256": "abc123",
"size": 134217728
},
"installed": {
"timestamp": "2025-01-15T10:30:00Z",
"count": 3
},
"activated": {
"timestamp": "2025-01-15T10:31:00Z",
"count": 3
}
}
}
},
{
"rootfs.1": {
"bootname": "B",
"class": "rootfs",
"state": "inactive",
"slot_status": {
"bundle": {
"compatible": "Infix x86_64",
"version": "24.10.0"
},
"checksum": {
"sha256": "def456",
"size": 130000000
},
"installed": {
"timestamp": "2024-10-01T08:00:00Z",
"count": 1
},
"activated": {
"timestamp": "2024-10-01T08:01:00Z",
"count": 1
}
}
}
}
]
}`
testBootOrder = "BOOT_ORDER=A B\n"
)
func TestBootPlatform(t *testing.T) {
fs := &testutil.MockFileReader{
Files: map[string][]byte{
"/etc/os-release": []byte(testOSRelease),
},
}
raw := BootPlatform(fs)
if raw == nil {
t.Fatal("BootPlatform returned nil")
}
var result map[string]interface{}
if err := json.Unmarshal(raw, &result); err != nil {
t.Fatalf("unmarshal: %v", err)
}
platform, ok := result["platform"].(map[string]interface{})
if !ok {
t.Fatal("missing platform key")
}
checks := map[string]string{
"os-name": "Infix",
"os-version": "25.01.0",
"os-release": "v25.01.0",
"machine": "x86_64",
}
for key, expected := range checks {
got, ok := platform[key].(string)
if !ok || got != expected {
t.Fatalf("platform[%q]: expected %q, got %v", key, expected, platform[key])
}
}
}
func TestBootPlatformMissingFile(t *testing.T) {
fs := &testutil.MockFileReader{Files: map[string][]byte{}}
raw := BootPlatform(fs)
if raw != nil {
t.Fatalf("expected nil for missing os-release, got %s", raw)
}
}
func TestBootSoftware(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"rauc status --detailed --output-format=json": []byte(testRaucStatus),
"fw_printenv BOOT_ORDER": []byte(testBootOrder),
},
Errors: map[string]error{},
}
raw := BootSoftware(context.Background(), runner)
if raw == nil {
t.Fatal("BootSoftware returned nil")
}
var result map[string]interface{}
if err := json.Unmarshal(raw, &result); err != nil {
t.Fatalf("unmarshal: %v", err)
}
sw, ok := result["infix-system:software"].(map[string]interface{})
if !ok {
t.Fatal("missing infix-system:software key")
}
if sw["compatible"] != "Infix x86_64" {
t.Fatalf("compatible: expected 'Infix x86_64', got %v", sw["compatible"])
}
if sw["booted"] != "rootfs.0" {
t.Fatalf("booted: expected 'rootfs.0', got %v", sw["booted"])
}
bootOrder, ok := sw["boot-order"].([]interface{})
if !ok || len(bootOrder) != 2 {
t.Fatalf("expected boot-order [A B], got %v", sw["boot-order"])
}
if bootOrder[0] != "A" || bootOrder[1] != "B" {
t.Fatalf("boot-order: expected [A B], got %v", bootOrder)
}
slots, ok := sw["slot"].([]interface{})
if !ok || len(slots) != 2 {
t.Fatalf("expected 2 slots, got %v", sw["slot"])
}
}
func TestBootSoftwareAllCommandsFail(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{},
Errors: map[string]error{
"rauc status --detailed --output-format=json": fmt.Errorf("not found"),
"fw_printenv BOOT_ORDER": fmt.Errorf("not found"),
"grub-editenv /mnt/aux/grub/grubenv list": fmt.Errorf("not found"),
},
}
raw := BootSoftware(context.Background(), runner)
if raw == nil {
t.Fatal("BootSoftware should return non-nil even when all commands fail")
}
var result map[string]interface{}
json.Unmarshal(raw, &result)
sw := result["infix-system:software"].(map[string]interface{})
if _, ok := sw["boot-order"]; ok {
t.Fatal("boot-order should not be present when commands fail")
}
}
func TestReadBootOrderFwPrintenv(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"fw_printenv BOOT_ORDER": []byte("BOOT_ORDER=A B\n"),
},
Errors: map[string]error{},
}
order := ReadBootOrder(context.Background(), runner)
if len(order) != 2 || order[0] != "A" || order[1] != "B" {
t.Fatalf("expected [A B], got %v", order)
}
}
func TestReadBootOrderGrubFallback(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"grub-editenv /mnt/aux/grub/grubenv list": []byte("ORDER=B A\n"),
},
Errors: map[string]error{
"fw_printenv BOOT_ORDER": fmt.Errorf("command not found"),
},
}
order := ReadBootOrder(context.Background(), runner)
if len(order) != 2 || order[0] != "B" || order[1] != "A" {
t.Fatalf("expected [B A], got %v", order)
}
}
func TestReadBootOrderBothFail(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{},
Errors: map[string]error{
"fw_printenv BOOT_ORDER": fmt.Errorf("not found"),
"grub-editenv /mnt/aux/grub/grubenv list": fmt.Errorf("not found"),
},
}
order := ReadBootOrder(context.Background(), runner)
if order != nil {
t.Fatalf("expected nil, got %v", order)
}
}
@@ -0,0 +1,55 @@
// Package collector defines the Collector interface and the RunAll
// scheduler that drives periodic data collection into the Tree.
package collector
import (
"context"
"log"
"sync"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
// Collector gathers operational data and writes it to the Tree.
type Collector interface {
Name() string
Interval() time.Duration
Collect(ctx context.Context, t *tree.Tree) error
}
// RunAll starts one goroutine per Collector, each ticking at the
// collector's configured interval. A failed Collect is logged and
// retried on the next tick. All goroutines exit when ctx is cancelled.
func RunAll(ctx context.Context, wg *sync.WaitGroup, t *tree.Tree, collectors []Collector, pokeCh <-chan struct{}) {
for _, c := range collectors {
wg.Add(1)
go runOne(ctx, wg, t, c, pokeCh)
}
}
func runOne(ctx context.Context, wg *sync.WaitGroup, t *tree.Tree, c Collector, pokeCh <-chan struct{}) {
defer wg.Done()
if err := c.Collect(ctx, t); err != nil {
log.Printf("collector %s: initial: %v", c.Name(), err)
}
ticker := time.NewTicker(c.Interval())
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := c.Collect(ctx, t); err != nil {
log.Printf("collector %s: %v", c.Name(), err)
}
case <-pokeCh:
if err := c.Collect(ctx, t); err != nil {
log.Printf("collector %s: poke: %v", c.Name(), err)
}
}
}
}
@@ -0,0 +1,501 @@
package collector
import (
"context"
"encoding/json"
"fmt"
"log"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
var sizeRe = regexp.MustCompile(`(?i)^\s*([0-9.]+)\s*([KMGT]?I?B)?\s*$`)
// ContainerCollector gathers infix-containers operational data.
type ContainerCollector struct {
cmd CommandRunner
fs FileReader
interval time.Duration
}
// NewContainerCollector creates a ContainerCollector with the given dependencies.
func NewContainerCollector(cmd CommandRunner, fs FileReader, interval time.Duration) *ContainerCollector {
return &ContainerCollector{cmd: cmd, fs: fs, interval: interval}
}
// Name implements Collector.
func (c *ContainerCollector) Name() string { return "containers" }
// Interval implements Collector.
func (c *ContainerCollector) Interval() time.Duration { return c.interval }
// Collect implements Collector. It produces one tree key:
// "infix-containers:containers".
func (c *ContainerCollector) Collect(ctx context.Context, t *tree.Tree) error {
data := c.collectJSON(ctx)
if data == nil {
// No containers: remove the key rather than leaving a bare
// "containers" node, so clients see the feature as absent.
t.Delete("infix-containers:containers")
return nil
}
t.Set("infix-containers:containers", data)
return nil
}
// collectJSON returns the operational containers subtree, or nil when no
// container exists. Returning nil (not an empty {"container":[]}) lets the
// caller drop the key entirely so an enabled-but-idle container feature does
// not surface as operational data.
func (c *ContainerCollector) collectJSON(ctx context.Context) json.RawMessage {
containers := []interface{}{}
psList := c.podmanPS(ctx)
for _, ps := range psList {
cont := c.container(ctx, ps)
if cont != nil {
containers = append(containers, cont)
}
}
if len(containers) == 0 {
return nil
}
out := map[string]interface{}{
"container": containers,
}
data, err := json.Marshal(out)
if err != nil {
return nil
}
return data
}
// CollectContainers runs a full container collection and returns the
// result as JSON suitable for tree.Set("infix-containers:containers").
func CollectContainers(cmd CommandRunner, fs FileReader) json.RawMessage {
c := &ContainerCollector{cmd: cmd, fs: fs}
return c.collectJSON(context.TODO())
}
func (c *ContainerCollector) podmanPS(ctx context.Context) []map[string]interface{} {
out, err := c.cmd.Run(ctx, "podman", "ps", "-a", "--format=json")
if err != nil {
log.Printf("collector containers: ps: %v", err)
return nil
}
var list []map[string]interface{}
if err := json.Unmarshal(out, &list); err == nil {
return list
}
var generic []interface{}
if err := json.Unmarshal(out, &generic); err != nil {
log.Printf("collector containers: ps parse: %v", err)
return nil
}
for _, item := range generic {
if m, ok := item.(map[string]interface{}); ok {
list = append(list, m)
}
}
return list
}
func (c *ContainerCollector) podmanInspect(ctx context.Context, name string) map[string]interface{} {
out, err := c.cmd.Run(ctx, "podman", "inspect", name)
if err != nil {
log.Printf("collector containers: inspect %s: %v", name, err)
return map[string]interface{}{}
}
var list []map[string]interface{}
if err := json.Unmarshal(out, &list); err == nil && len(list) > 0 {
return list[0]
}
var generic []interface{}
if err := json.Unmarshal(out, &generic); err == nil {
for _, item := range generic {
if m, ok := item.(map[string]interface{}); ok {
return m
}
}
}
var single map[string]interface{}
if err := json.Unmarshal(out, &single); err == nil {
return single
}
log.Printf("collector containers: inspect %s parse: invalid json", name)
return map[string]interface{}{}
}
func (c *ContainerCollector) resourceStats(ctx context.Context, name string) map[string]interface{} {
out, err := c.cmd.Run(ctx, "podman", "stats", "--no-stream", "--format", "json", "--no-reset", name)
if err != nil {
log.Printf("collector containers: stats %s: %v", name, err)
return nil
}
var statsList []map[string]interface{}
if err := json.Unmarshal(out, &statsList); err != nil {
var single map[string]interface{}
if err2 := json.Unmarshal(out, &single); err2 != nil {
log.Printf("collector containers: stats %s parse: %v", name, err)
return nil
}
statsList = append(statsList, single)
}
if len(statsList) == 0 {
return nil
}
stat := statsList[0]
rusage := make(map[string]interface{})
if memUsage, ok := stat["mem_usage"].(string); ok {
parts := strings.SplitN(memUsage, "/", 2)
if len(parts) == 2 {
memKiB := parseSizeKiB(strings.TrimSpace(parts[0]))
rusage["memory"] = strconv.Itoa(memKiB)
}
}
if cpuPercent, ok := stat["cpu_percent"].(string); ok {
cpuPercent = strings.TrimSpace(strings.TrimSuffix(cpuPercent, "%"))
if cpuVal, err := strconv.ParseFloat(cpuPercent, 64); err == nil {
rusage["cpu"] = fmt.Sprintf("%.2f", cpuVal)
}
}
if blockIO, ok := stat["block_io"].(string); ok {
parts := strings.SplitN(blockIO, "/", 2)
if len(parts) == 2 {
readKiB := parseSizeKiB(strings.TrimSpace(parts[0]))
writeKiB := parseSizeKiB(strings.TrimSpace(parts[1]))
bio := make(map[string]interface{})
if readKiB > 0 {
bio["read"] = strconv.Itoa(readKiB)
}
if writeKiB > 0 {
bio["write"] = strconv.Itoa(writeKiB)
}
rusage["block-io"] = bio
}
}
if netIO, ok := stat["net_io"].(string); ok {
parts := strings.SplitN(netIO, "/", 2)
if len(parts) == 2 {
rxKiB := parseSizeKiB(strings.TrimSpace(parts[0]))
txKiB := parseSizeKiB(strings.TrimSpace(parts[1]))
nio := make(map[string]interface{})
if rxKiB > 0 {
nio["received"] = strconv.Itoa(rxKiB)
}
if txKiB > 0 {
nio["sent"] = strconv.Itoa(txKiB)
}
rusage["net-io"] = nio
}
}
if pids, ok := stat["pids"]; ok {
pidInt := toInt(pids)
rusage["pids"] = pidInt
}
if len(rusage) == 0 {
return nil
}
return rusage
}
func (c *ContainerCollector) readCgroupLimits(inspect map[string]interface{}) map[string]interface{} {
stateRaw, ok := inspect["State"]
if !ok {
return nil
}
state, ok := stateRaw.(map[string]interface{})
if !ok {
return nil
}
cgroupPath, ok := state["CgroupPath"].(string)
if !ok || cgroupPath == "" {
return nil
}
cgroupBase := "/sys/fs/cgroup" + cgroupPath
memVal := 0
cpuVal := 0
if data, err := c.fs.ReadFile(filepath.Join(cgroupBase, "memory.max")); err == nil {
memVal = parseCgroupMemory(strings.TrimSpace(string(data)))
}
if data, err := c.fs.ReadFile(filepath.Join(cgroupBase, "cpu.max")); err == nil {
cpuVal = parseCgroupCPU(strings.TrimSpace(string(data)))
}
if memVal <= 0 && cpuVal <= 0 {
return nil
}
result := make(map[string]interface{})
if memVal > 0 {
result["memory"] = strconv.Itoa(memVal)
}
if cpuVal > 0 {
result["cpu"] = cpuVal
}
return result
}
func (c *ContainerCollector) network(ps map[string]interface{}, inspect map[string]interface{}) map[string]interface{} {
networkSettingsRaw, hasNetworkSettings := inspect["NetworkSettings"]
if hasNetworkSettings {
if networkSettings, ok := networkSettingsRaw.(map[string]interface{}); ok {
if networksRaw, ok := networkSettings["Networks"]; ok {
if networks, ok := networksRaw.(map[string]interface{}); ok {
if _, ok := networks["host"]; ok {
return map[string]interface{}{"host": true}
}
}
}
}
}
net := map[string]interface{}{
"interface": []interface{}{},
"publish": []interface{}{},
}
networks := asStringSlice(ps["Networks"])
ifaces := net["interface"].([]interface{})
for _, n := range networks {
ifaces = append(ifaces, map[string]interface{}{"name": n})
}
net["interface"] = ifaces
running := strings.EqualFold(asString(ps["State"]), "running")
if !running {
return net
}
portsRaw, ok := ps["Ports"]
if !ok {
return net
}
ports, ok := portsRaw.([]interface{})
if !ok || len(ports) == 0 {
return net
}
publish := net["publish"].([]interface{})
for _, portRaw := range ports {
port, ok := portRaw.(map[string]interface{})
if !ok {
continue
}
hostIP := asString(port["host_ip"])
hostPort := asString(port["host_port"])
if hostPort == "" {
hostPort = strconv.Itoa(toInt(port["host_port"]))
}
containerPort := asString(port["container_port"])
if containerPort == "" {
containerPort = strconv.Itoa(toInt(port["container_port"]))
}
protocol := asString(port["protocol"])
if hostPort == "0" || hostPort == "" || containerPort == "0" || containerPort == "" || protocol == "" {
continue
}
addr := ""
if hostIP != "" {
addr = hostIP + ":"
}
publish = append(publish, fmt.Sprintf("%s%s:%s/%s", addr, hostPort, containerPort, protocol))
}
net["publish"] = publish
return net
}
func (c *ContainerCollector) container(ctx context.Context, ps map[string]interface{}) map[string]interface{} {
names := asStringSlice(ps["Names"])
if len(names) == 0 {
return nil
}
name := names[0]
running := strings.EqualFold(asString(ps["State"]), "running")
out := map[string]interface{}{
"name": name,
"id": asString(ps["Id"]),
"image": asString(ps["Image"]),
"image-id": asString(ps["ImageID"]),
"running": running,
"status": asString(ps["Status"]),
}
inspect := c.podmanInspect(ctx, name)
// Report the actual running command line as the config-false
// "cmdline" leaf (an unrestricted string), built from inspect's
// Path + Args like the legacy yanger collector. Do NOT report it
// into the config-true "command" leaf: that leaf has a restrictive
// pattern and a real command line (e.g. one containing "&&" or
// quotes) fails YANG validation, which rejects the entire
// containers subtree on read.
if path := asString(inspect["Path"]); path != "" {
parts := append([]string{path}, asStringSlice(inspect["Args"])...)
out["cmdline"] = strings.Join(parts, " ")
}
if net := c.network(ps, inspect); len(net) > 0 {
out["network"] = net
}
if limits := c.readCgroupLimits(inspect); limits != nil {
out["resource-limit"] = limits
}
if running {
if usage := c.resourceStats(ctx, name); usage != nil {
out["resource-usage"] = usage
}
}
return out
}
func asString(v interface{}) string {
s, ok := v.(string)
if ok {
return s
}
return ""
}
func asStringSlice(v interface{}) []string {
switch vv := v.(type) {
case []string:
return vv
case []interface{}:
out := make([]string, 0, len(vv))
for _, e := range vv {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
return out
case string:
if vv == "" {
return nil
}
return splitLines(vv)
default:
return nil
}
}
func parseSizeKiB(sizeStr string) int {
if strings.TrimSpace(sizeStr) == "" {
return 0
}
m := sizeRe.FindStringSubmatch(strings.ToUpper(strings.TrimSpace(sizeStr)))
if len(m) < 2 {
return 0
}
value, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0
}
unit := "B"
if len(m) >= 3 && m[2] != "" {
unit = strings.ToUpper(m[2])
}
multipliers := map[string]float64{
"B": 1.0 / 1024.0,
"KB": 1000.0 / 1024.0,
"KIB": 1,
"MB": (1000.0 * 1000.0) / 1024.0,
"MIB": 1024,
"GB": (1000.0 * 1000.0 * 1000.0) / 1024.0,
"GIB": 1024 * 1024,
"TB": (1000.0 * 1000.0 * 1000.0 * 1000.0) / 1024.0,
"TIB": 1024 * 1024 * 1024,
}
mult, ok := multipliers[unit]
if !ok {
mult = 1
}
return int(value * mult)
}
func parseCgroupMemory(memStr string) int {
memStr = strings.TrimSpace(memStr)
if memStr == "" || memStr == "max" {
return 0
}
memBytes, err := strconv.ParseUint(memStr, 10, 64)
if err != nil {
return 0
}
return int(memBytes / 1024)
}
func parseCgroupCPU(cpuStr string) int {
cpuStr = strings.TrimSpace(cpuStr)
if cpuStr == "" {
return 0
}
parts := strings.Fields(cpuStr)
if len(parts) != 2 || parts[0] == "max" {
return 0
}
quota, err := strconv.Atoi(parts[0])
if err != nil {
return 0
}
period, err := strconv.Atoi(parts[1])
if err != nil || period == 0 {
return 0
}
return (quota * 1000) / period
}
@@ -0,0 +1,452 @@
package collector
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/testutil"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
func collectContainers(t *testing.T, runner *testutil.MockRunner, fs *testutil.MockFileReader) map[string]interface{} {
t.Helper()
c := NewContainerCollector(runner, fs, 30*time.Second)
tr := tree.New()
if err := c.Collect(context.Background(), tr); err != nil {
t.Fatalf("Collect failed: %v", err)
}
raw := tr.Get("infix-containers:containers")
if raw == nil {
t.Fatal("missing infix-containers:containers in tree")
}
out := make(map[string]interface{})
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal containers: %v", err)
}
return out
}
func containerList(t *testing.T, data map[string]interface{}) []interface{} {
t.Helper()
containers, ok := data["container"].([]interface{})
if !ok {
t.Fatalf("missing container list: %v", data)
}
return containers
}
func TestContainerBasicInfo(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"podman ps -a --format=json": []byte(`[
{
"Names": ["web"],
"Id": "abc123",
"Image": "docker.io/library/nginx:latest",
"ImageID": "sha256:image",
"State": "running",
"Status": "Up 2 hours",
"Command": ["nginx", "-g", "daemon off;"],
"Networks": ["podman0"],
"Ports": []
}
]`),
"podman inspect web": []byte(`[{"Path":"nginx","Args":["-g","daemon off;"]}]`),
"podman stats --no-stream --format json --no-reset web": []byte(`[]`),
},
Errors: map[string]error{},
}
fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}}
out := collectContainers(t, runner, fs)
containers := containerList(t, out)
if len(containers) != 1 {
t.Fatalf("expected 1 container, got %d", len(containers))
}
c := containers[0].(map[string]interface{})
if c["name"] != "web" {
t.Fatalf("name: expected web, got %v", c["name"])
}
if c["id"] != "abc123" {
t.Fatalf("id: expected abc123, got %v", c["id"])
}
if c["image"] != "docker.io/library/nginx:latest" {
t.Fatalf("image mismatch: %v", c["image"])
}
if c["status"] != "Up 2 hours" {
t.Fatalf("status mismatch: %v", c["status"])
}
if c["cmdline"] != "nginx -g daemon off;" {
t.Fatalf("cmdline mismatch: %v", c["cmdline"])
}
if c["running"] != true {
t.Fatalf("running expected true, got %v", c["running"])
}
}
func TestContainerHostNetwork(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"podman ps -a --format=json": []byte(`[
{
"Names": ["hostnet"],
"Id": "id1",
"Image": "img",
"ImageID": "sha256:1",
"State": "running",
"Status": "Up",
"Command": ["sleep", "60"],
"Networks": ["podman0"],
"Ports": [{"host_ip":"", "host_port":8080, "container_port":80, "protocol":"tcp"}]
}
]`),
"podman inspect hostnet": []byte(`[{"NetworkSettings":{"Networks":{"host":{}}}}]`),
"podman stats --no-stream --format json --no-reset hostnet": []byte(`[]`),
},
Errors: map[string]error{},
}
fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}}
out := collectContainers(t, runner, fs)
c := containerList(t, out)[0].(map[string]interface{})
net, ok := c["network"].(map[string]interface{})
if !ok {
t.Fatalf("missing network: %v", c)
}
if net["host"] != true {
t.Fatalf("expected host network true, got %v", net["host"])
}
}
func TestContainerBridgeNetwork(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"podman ps -a --format=json": []byte(`[
{
"Names": ["bridge"],
"Id": "id2",
"Image": "img",
"ImageID": "sha256:2",
"State": "running",
"Status": "Up",
"Command": ["app"],
"Networks": ["podman0", "br0"],
"Ports": [
{"host_ip":"127.0.0.1", "host_port":8080, "container_port":80, "protocol":"tcp"},
{"host_ip":"", "host_port":8443, "container_port":443, "protocol":"tcp"}
]
}
]`),
"podman inspect bridge": []byte(`[{"NetworkSettings":{"Networks":{"bridge":{}}}}]`),
"podman stats --no-stream --format json --no-reset bridge": []byte(`[]`),
},
Errors: map[string]error{},
}
fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}}
out := collectContainers(t, runner, fs)
c := containerList(t, out)[0].(map[string]interface{})
net := c["network"].(map[string]interface{})
ifaces := net["interface"].([]interface{})
if len(ifaces) != 2 {
t.Fatalf("expected 2 interfaces, got %d", len(ifaces))
}
if ifaces[0].(map[string]interface{})["name"] != "podman0" {
t.Fatalf("interface[0] mismatch: %v", ifaces[0])
}
publish := net["publish"].([]interface{})
if len(publish) != 2 {
t.Fatalf("expected 2 published ports, got %d", len(publish))
}
if publish[0] != "127.0.0.1:8080:80/tcp" {
t.Fatalf("publish[0] mismatch: %v", publish[0])
}
if publish[1] != "8443:443/tcp" {
t.Fatalf("publish[1] mismatch: %v", publish[1])
}
}
func TestContainerCgroupLimits(t *testing.T) {
cgroupPath := "/machine.slice/libpod-abc.scope"
runner := &testutil.MockRunner{
Results: map[string][]byte{
"podman ps -a --format=json": []byte(`[
{"Names":["limited"],"Id":"id3","Image":"img","ImageID":"sha256:3","State":"exited","Status":"Exited","Command":["app"],"Networks":[],"Ports":[]}
]`),
"podman inspect limited": []byte(fmt.Sprintf(`[{"State":{"CgroupPath":%q},"NetworkSettings":{"Networks":{"bridge":{}}}}]`, cgroupPath)),
},
Errors: map[string]error{},
}
fs := &testutil.MockFileReader{
Files: map[string][]byte{
"/sys/fs/cgroup/machine.slice/libpod-abc.scope/memory.max": []byte("1073741824\n"),
"/sys/fs/cgroup/machine.slice/libpod-abc.scope/cpu.max": []byte("200000 100000\n"),
},
Globs: map[string][]string{},
}
out := collectContainers(t, runner, fs)
c := containerList(t, out)[0].(map[string]interface{})
limit, ok := c["resource-limit"].(map[string]interface{})
if !ok {
t.Fatalf("missing resource-limit: %v", c)
}
if limit["memory"] != "1048576" {
t.Fatalf("memory limit expected 1048576, got %v", limit["memory"])
}
if toInt(limit["cpu"]) != 2000 {
t.Fatalf("cpu limit expected 2000, got %v", limit["cpu"])
}
}
func TestContainerCgroupUnlimited(t *testing.T) {
cgroupPath := "/machine.slice/libpod-unlimited.scope"
runner := &testutil.MockRunner{
Results: map[string][]byte{
"podman ps -a --format=json": []byte(`[
{"Names":["nolimit"],"Id":"id4","Image":"img","ImageID":"sha256:4","State":"exited","Status":"Exited","Command":["app"],"Networks":[],"Ports":[]}
]`),
"podman inspect nolimit": []byte(fmt.Sprintf(`[{"State":{"CgroupPath":%q},"NetworkSettings":{"Networks":{"bridge":{}}}}]`, cgroupPath)),
},
Errors: map[string]error{},
}
fs := &testutil.MockFileReader{
Files: map[string][]byte{
"/sys/fs/cgroup/machine.slice/libpod-unlimited.scope/memory.max": []byte("max\n"),
"/sys/fs/cgroup/machine.slice/libpod-unlimited.scope/cpu.max": []byte("max 100000\n"),
},
Globs: map[string][]string{},
}
out := collectContainers(t, runner, fs)
c := containerList(t, out)[0].(map[string]interface{})
if _, ok := c["resource-limit"]; ok {
t.Fatalf("resource-limit should be omitted for unlimited cgroup values: %v", c["resource-limit"])
}
}
func TestContainerResourceStats(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"podman ps -a --format=json": []byte(`[
{
"Names":["stats"],"Id":"id5","Image":"img","ImageID":"sha256:5",
"State":"running","Status":"Up","Command":["app"],"Networks":["podman0"],"Ports":[]
}
]`),
"podman inspect stats": []byte(`[{"NetworkSettings":{"Networks":{"bridge":{}}}}]`),
"podman stats --no-stream --format json --no-reset stats": []byte(`[
{
"mem_usage":"123.4MB / 1.5GB",
"cpu_percent":"12.34%",
"block_io":"1.2MB / 3.4GB",
"net_io":"1.2MB / 3.4GB",
"pids":5
}
]`),
},
Errors: map[string]error{},
}
fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}}
out := collectContainers(t, runner, fs)
c := containerList(t, out)[0].(map[string]interface{})
usage, ok := c["resource-usage"].(map[string]interface{})
if !ok {
t.Fatalf("missing resource-usage: %v", c)
}
if usage["memory"] != "120507" {
t.Fatalf("memory usage expected 120507, got %v", usage["memory"])
}
if usage["cpu"] != "12.34" {
t.Fatalf("cpu usage expected 12.34, got %v", usage["cpu"])
}
bio := usage["block-io"].(map[string]interface{})
if bio["read"] != "1171" {
t.Fatalf("block-io read expected 1171, got %v", bio["read"])
}
if bio["write"] != "3320312" {
t.Fatalf("block-io write expected 3320312, got %v", bio["write"])
}
nio := usage["net-io"].(map[string]interface{})
if nio["received"] != "1171" {
t.Fatalf("net-io received expected 1171, got %v", nio["received"])
}
if nio["sent"] != "3320312" {
t.Fatalf("net-io sent expected 3320312, got %v", nio["sent"])
}
if toInt(usage["pids"]) != 5 {
t.Fatalf("pids expected 5, got %v", usage["pids"])
}
}
func TestContainerStopped(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"podman ps -a --format=json": []byte(`[
{
"Names":["stopped"],"Id":"id6","Image":"img","ImageID":"sha256:6",
"State":"exited","Status":"Exited (0)","Command":["app"],
"Networks":["podman0"],"Ports":[{"host_ip":"","host_port":8080,"container_port":80,"protocol":"tcp"}]
}
]`),
"podman inspect stopped": []byte(`[{"NetworkSettings":{"Networks":{"bridge":{}}}}]`),
},
Errors: map[string]error{},
}
fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}}
out := collectContainers(t, runner, fs)
c := containerList(t, out)[0].(map[string]interface{})
if _, ok := c["resource-usage"]; ok {
t.Fatalf("stopped container must not include resource-usage: %v", c["resource-usage"])
}
net := c["network"].(map[string]interface{})
publish := net["publish"].([]interface{})
if len(publish) != 0 {
t.Fatalf("stopped container must not include published ports, got %v", publish)
}
}
func TestContainerMultiple(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"podman ps -a --format=json": []byte(`[
{"Names":["one"],"Id":"id7","Image":"img1","ImageID":"sha256:7","State":"running","Status":"Up","Command":["a"],"Networks":[],"Ports":[]},
{"Names":["two"],"Id":"id8","Image":"img2","ImageID":"sha256:8","State":"exited","Status":"Exited","Command":["b"],"Networks":[],"Ports":[]}
]`),
"podman inspect one": []byte(`[{}]`),
"podman stats --no-stream --format json --no-reset one": []byte(`[]`),
"podman inspect two": []byte(`[{}]`),
},
Errors: map[string]error{},
}
fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}}
out := collectContainers(t, runner, fs)
containers := containerList(t, out)
if len(containers) != 2 {
t.Fatalf("expected 2 containers, got %d", len(containers))
}
if containers[0].(map[string]interface{})["name"] != "one" {
t.Fatalf("first container name mismatch: %v", containers[0])
}
if containers[1].(map[string]interface{})["name"] != "two" {
t.Fatalf("second container name mismatch: %v", containers[1])
}
}
func TestParseSizeKiB(t *testing.T) {
tests := []struct {
name string
input string
expected int
}{
{name: "mb", input: "1.5MB", expected: 1464},
{name: "kb", input: "512kB", expected: 500},
{name: "gib", input: "2GiB", expected: 2097152},
{name: "mib", input: "64MiB", expected: 65536},
{name: "bytes", input: "2048B", expected: 2},
{name: "empty", input: "", expected: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseSizeKiB(tt.input)
if got != tt.expected {
t.Errorf("parseSizeKiB(%q): expected %d, got %d", tt.input, tt.expected, got)
}
})
}
}
func TestParseCgroupMemory(t *testing.T) {
tests := []struct {
name string
input string
expected int
}{
{name: "max", input: "max", expected: 0},
{name: "bytes", input: "1073741824", expected: 1048576},
{name: "empty", input: "", expected: 0},
{name: "invalid", input: "abc", expected: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseCgroupMemory(tt.input)
if got != tt.expected {
t.Errorf("parseCgroupMemory(%q): expected %d, got %d", tt.input, tt.expected, got)
}
})
}
}
func TestParseCgroupCPU(t *testing.T) {
tests := []struct {
name string
input string
expected int
}{
{name: "max", input: "max 100000", expected: 0},
{name: "limited", input: "50000 100000", expected: 500},
{name: "empty", input: "", expected: 0},
{name: "invalid", input: "abc", expected: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseCgroupCPU(tt.input)
if got != tt.expected {
t.Errorf("parseCgroupCPU(%q): expected %d, got %d", tt.input, tt.expected, got)
}
})
}
}
func TestContainerGracefulDegradation(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{},
Errors: map[string]error{
"podman ps -a --format=json": fmt.Errorf("podman not found"),
},
}
fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}}
c := NewContainerCollector(runner, fs, 30*time.Second)
tr := tree.New()
if err := c.Collect(context.Background(), tr); err != nil {
t.Fatalf("Collect failed: %v", err)
}
// With no containers the key must be absent, not a bare
// {"container":[]} node -- otherwise an enabled-but-idle container
// feature surfaces as operational data.
if raw := tr.Get("infix-containers:containers"); raw != nil {
t.Fatalf("expected no containers key when podman ps fails, got %s", raw)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,398 @@
package collector
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/testutil"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
func collectHardware(t *testing.T, c *HardwareCollector) []interface{} {
t.Helper()
tr := tree.New()
if err := c.Collect(context.Background(), tr); err != nil {
t.Fatalf("Collect failed: %v", err)
}
raw := tr.Get("ietf-hardware:hardware")
if raw == nil {
t.Fatal("missing ietf-hardware:hardware in tree")
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal hardware: %v", err)
}
components, ok := out["component"].([]interface{})
if !ok {
t.Fatalf("component list missing or invalid: %v", out["component"])
}
return components
}
func getComponentByName(components []interface{}, name string) map[string]interface{} {
for _, c := range components {
m, ok := c.(map[string]interface{})
if !ok {
continue
}
if m["name"] == name {
return m
}
}
return nil
}
func containsComponentWithClass(components []interface{}, class string) bool {
for _, c := range components {
m, ok := c.(map[string]interface{})
if !ok {
continue
}
if m["class"] == class {
return true
}
}
return false
}
func newHardwareCollector(r *testutil.MockRunner, fs *testutil.MockFileReader) *HardwareCollector {
return NewHardwareCollector(r, fs, 30*time.Second, false, false)
}
func TestHardwareMotherboard(t *testing.T) {
runner := &testutil.MockRunner{Results: map[string][]byte{}, Errors: map[string]error{}}
fs := &testutil.MockFileReader{Files: map[string][]byte{
"/run/system.json": []byte(`{"vendor":"Acme","product-name":"Router-1","serial-number":"SN123","part-number":"PN99","mac-address":"00:11:22:33:44:55"}`),
}, Globs: map[string][]string{}}
components := collectHardware(t, newHardwareCollector(runner, fs))
mb := getComponentByName(components, "mainboard")
if mb == nil {
t.Fatal("mainboard component not found")
}
if mb["class"] != "iana-hardware:chassis" {
t.Fatalf("mainboard class: expected chassis, got %v", mb["class"])
}
if mb["mfg-name"] != "Acme" || mb["model-name"] != "Router-1" || mb["serial-num"] != "SN123" {
t.Fatalf("mainboard identity fields mismatch: %v", mb)
}
if mb["hardware-rev"] != "PN99" {
t.Fatalf("mainboard hardware-rev mismatch: %v", mb["hardware-rev"])
}
if mb["infix-hardware:phys-address"] != "00:11:22:33:44:55" {
t.Fatalf("mainboard phys-address mismatch: %v", mb["infix-hardware:phys-address"])
}
state, ok := mb["state"].(map[string]interface{})
if !ok {
t.Fatalf("mainboard state missing: %v", mb["state"])
}
if state["admin-state"] != "unknown" || state["oper-state"] != "enabled" {
t.Fatalf("mainboard state mismatch: %v", state)
}
}
func TestHardwareVPD(t *testing.T) {
runner := &testutil.MockRunner{Results: map[string][]byte{}, Errors: map[string]error{}}
fs := &testutil.MockFileReader{Files: map[string][]byte{
"/run/system.json": []byte(`{
"vpd": {
"slot0": {
"board": "board0",
"data": {
"manufacture-date": "04/11/2026 13:14:15",
"manufacturer": "VPD Inc",
"product-name": "X1",
"serial-number": "VPD-123",
"foo": "bar",
"vendor-extension": [[32473, "aa55"]]
}
}
}
}`),
}, Globs: map[string][]string{}}
components := collectHardware(t, newHardwareCollector(runner, fs))
vpd := getComponentByName(components, "board0")
if vpd == nil {
t.Fatal("vpd component board0 not found")
}
if vpd["class"] != "infix-hardware:vpd" {
t.Fatalf("vpd class mismatch: %v", vpd["class"])
}
if vpd["mfg-date"] != "2026-04-11T13:14:15Z" {
t.Fatalf("mfg-date mismatch: %v", vpd["mfg-date"])
}
if vpd["serial-num"] != "VPD-123" {
t.Fatalf("serial-num mismatch: %v", vpd["serial-num"])
}
vpdData, ok := vpd["infix-hardware:vpd-data"].(map[string]interface{})
if !ok {
t.Fatalf("vpd-data missing: %v", vpd["infix-hardware:vpd-data"])
}
if vpdData["foo"] != "bar" {
t.Fatalf("vpd-data foo mismatch: %v", vpdData["foo"])
}
extList, ok := vpdData["infix-hardware:vendor-extension"].([]interface{})
if !ok || len(extList) != 1 {
t.Fatalf("vendor-extension missing: %v", vpdData["infix-hardware:vendor-extension"])
}
ext := extList[0].(map[string]interface{})
if toInt(ext["iana-enterprise-number"]) != 32473 || ext["extension-data"] != "aa55" {
t.Fatalf("vendor-extension mismatch: %v", ext)
}
}
func TestHardwareUSBPorts(t *testing.T) {
runner := &testutil.MockRunner{Results: map[string][]byte{}, Errors: map[string]error{}}
fs := &testutil.MockFileReader{Files: map[string][]byte{
"/run/system.json": []byte(`{"usb-ports":[{"name":"usb-a","path":"/sys/devices/usb-a"},{"name":"usb-b","path":"/sys/devices/usb-b"}]}`),
"/sys/devices/usb-a/authorized_default": []byte("1\n"),
"/sys/devices/usb-b/authorized_default": []byte("0\n"),
}, Globs: map[string][]string{}}
components := collectHardware(t, newHardwareCollector(runner, fs))
usbA := getComponentByName(components, "usb-a")
usbB := getComponentByName(components, "usb-b")
if usbA == nil || usbB == nil {
t.Fatalf("usb components missing: usb-a=%v usb-b=%v", usbA, usbB)
}
aState := usbA["state"].(map[string]interface{})
bState := usbB["state"].(map[string]interface{})
if aState["admin-state"] != "unlocked" || bState["admin-state"] != "locked" {
t.Fatalf("usb admin-state mismatch: a=%v b=%v", aState, bState)
}
}
func TestHardwareHwmonTemp(t *testing.T) {
runner := &testutil.MockRunner{Results: map[string][]byte{
"ls /sys/class/hwmon": []byte("hwmon0\n"),
"ls /sys/class/hwmon/hwmon0": []byte("name\ntemp1_input\ntemp1_label\n"),
}, Errors: map[string]error{}}
fs := &testutil.MockFileReader{Files: map[string][]byte{
"/run/system.json": []byte(`{}`),
"/sys/class/hwmon/hwmon0/name": []byte("cpu_thermal\n"),
"/sys/class/hwmon/hwmon0/temp1_input": []byte("42000\n"),
"/sys/class/hwmon/hwmon0/temp1_label": []byte("cpu_temp\n"),
}, Globs: map[string][]string{}}
components := collectHardware(t, newHardwareCollector(runner, fs))
if !containsComponentWithClass(components, "iana-hardware:sensor") {
t.Fatalf("expected at least one sensor component: %v", components)
}
sensor := getComponentByName(components, "cpu-temp")
if sensor == nil {
t.Fatalf("expected temp sensor cpu-temp, got: %v", components)
}
sd := sensor["sensor-data"].(map[string]interface{})
if toInt(sd["value"]) != 42000 || sd["value-type"] != "celsius" || sd["value-scale"] != "milli" {
t.Fatalf("temp sensor-data mismatch: %v", sd)
}
}
func TestHardwareHwmonFan(t *testing.T) {
runner := &testutil.MockRunner{Results: map[string][]byte{
"ls /sys/class/hwmon": []byte("hwmon1\n"),
"ls /sys/class/hwmon/hwmon1": []byte("name\nfan1_input\n"),
}, Errors: map[string]error{}}
fs := &testutil.MockFileReader{Files: map[string][]byte{
"/run/system.json": []byte(`{}`),
"/sys/class/hwmon/hwmon1/name": []byte("pwmfan\n"),
"/sys/class/hwmon/hwmon1/fan1_input": []byte("3200\n"),
}, Globs: map[string][]string{}}
components := collectHardware(t, newHardwareCollector(runner, fs))
sensor := getComponentByName(components, "pwmfan")
if sensor == nil {
t.Fatalf("expected fan sensor pwmfan, got: %v", components)
}
sd := sensor["sensor-data"].(map[string]interface{})
if toInt(sd["value"]) != 3200 || sd["value-type"] != "rpm" || sd["value-scale"] != "units" {
t.Fatalf("fan sensor-data mismatch: %v", sd)
}
}
func TestHardwareHwmonVoltage(t *testing.T) {
runner := &testutil.MockRunner{Results: map[string][]byte{
"ls /sys/class/hwmon": []byte("hwmon2\n"),
"ls /sys/class/hwmon/hwmon2": []byte("name\nin1_input\nin1_label\n"),
}, Errors: map[string]error{}}
fs := &testutil.MockFileReader{Files: map[string][]byte{
"/run/system.json": []byte(`{}`),
"/sys/class/hwmon/hwmon2/name": []byte("ina3221\n"),
"/sys/class/hwmon/hwmon2/in1_input": []byte("12000\n"),
"/sys/class/hwmon/hwmon2/in1_label": []byte("VCC\n"),
}, Globs: map[string][]string{}}
components := collectHardware(t, newHardwareCollector(runner, fs))
sensor := getComponentByName(components, "ina3221-VCC")
if sensor == nil {
t.Fatalf("expected voltage sensor ina3221-VCC, got: %v", components)
}
if sensor["description"] != "VCC" {
t.Fatalf("expected VCC description, got %v", sensor["description"])
}
sd := sensor["sensor-data"].(map[string]interface{})
if toInt(sd["value"]) != 12000 || sd["value-type"] != "volts-DC" || sd["value-scale"] != "milli" {
t.Fatalf("voltage sensor-data mismatch: %v", sd)
}
}
func TestHardwareHwmonMultiSensor(t *testing.T) {
runner := &testutil.MockRunner{Results: map[string][]byte{
"ls /sys/class/hwmon": []byte("hwmon3\n"),
"ls /sys/class/hwmon/hwmon3": []byte("name\ntemp1_input\ntemp1_label\nfan1_input\nfan1_label\ncurr1_input\npower1_input\n"),
}, Errors: map[string]error{}}
fs := &testutil.MockFileReader{Files: map[string][]byte{
"/run/system.json": []byte(`{}`),
"/sys/class/hwmon/hwmon3/name": []byte("sfp_2\n"),
"/sys/class/hwmon/hwmon3/temp1_input": []byte("33000\n"),
"/sys/class/hwmon/hwmon3/temp1_label": []byte("temp1\n"),
"/sys/class/hwmon/hwmon3/fan1_input": []byte("2000\n"),
"/sys/class/hwmon/hwmon3/fan1_label": []byte("fan1\n"),
"/sys/class/hwmon/hwmon3/curr1_input": []byte("1500\n"),
"/sys/class/hwmon/hwmon3/power1_input": []byte("2500000\n"),
}, Globs: map[string][]string{}}
components := collectHardware(t, newHardwareCollector(runner, fs))
parent := getComponentByName(components, "sfp2")
if parent == nil || parent["class"] != "iana-hardware:module" {
t.Fatalf("expected sfp2 parent module, got: %v", parent)
}
children := 0
hasCurrent := false
hasPower := false
for _, compRaw := range components {
comp, ok := compRaw.(map[string]interface{})
if !ok {
continue
}
if comp["parent"] != "sfp2" {
continue
}
children++
sd, _ := comp["sensor-data"].(map[string]interface{})
if sd != nil && sd["value-type"] == "amperes" {
hasCurrent = true
}
if sd != nil && sd["value-type"] == "watts" {
hasPower = true
}
}
if children < 4 {
t.Fatalf("expected at least 4 child sensors, got %d", children)
}
if !hasCurrent || !hasPower {
t.Fatalf("expected current and power sensors under parent: current=%v power=%v", hasCurrent, hasPower)
}
}
func TestHardwareThermalZone(t *testing.T) {
runner := &testutil.MockRunner{Results: map[string][]byte{
"ls /sys/class/thermal": []byte("thermal_zone0\n"),
}, Errors: map[string]error{}}
fs := &testutil.MockFileReader{Files: map[string][]byte{
"/run/system.json": []byte(`{}`),
"/sys/class/thermal/thermal_zone0/type": []byte("cpu-thermal\n"),
"/sys/class/thermal/thermal_zone0/temp": []byte("39000\n"),
}, Globs: map[string][]string{}}
components := collectHardware(t, newHardwareCollector(runner, fs))
sensor := getComponentByName(components, "cpu")
if sensor == nil {
t.Fatalf("expected thermal sensor cpu, got %v", components)
}
sd := sensor["sensor-data"].(map[string]interface{})
if toInt(sd["value"]) != 39000 || sd["value-type"] != "celsius" {
t.Fatalf("thermal sensor mismatch: %v", sd)
}
}
func TestHardwareNormalizeSensorName(t *testing.T) {
tests := []struct {
in string
want string
}{
{in: "sfp_2", want: "sfp2"},
{in: "mt7915_phy0", want: "phy0"},
{in: "marvell_alaska_tomte_phy7", want: "phy7"},
{in: "cpu_thermal", want: "cpu"},
{in: "gpu-thermal", want: "gpu"},
{in: "pwmfan", want: "pwmfan"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.in, func(t *testing.T) {
got := normalize_sensor_name(tt.in)
if got != tt.want {
t.Errorf("normalize_sensor_name(%q): expected %q, got %q", tt.in, tt.want, got)
}
})
}
}
func TestHardwareGracefulDegradation(t *testing.T) {
runner := &testutil.MockRunner{Results: map[string][]byte{}, Errors: map[string]error{}}
fs := &testutil.MockFileReader{Files: map[string][]byte{}, Globs: map[string][]string{}}
tr := tree.New()
c := newHardwareCollector(runner, fs)
if err := c.Collect(context.Background(), tr); err != nil {
t.Fatalf("Collect should not fail when all probes fail: %v", err)
}
raw := tr.Get("ietf-hardware:hardware")
if raw == nil {
t.Fatal("expected ietf-hardware:hardware key even on probe failures")
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal hardware: %v", err)
}
components, ok := out["component"].([]interface{})
if !ok {
t.Fatalf("component list missing: %v", out["component"])
}
if len(components) != 0 {
t.Fatalf("expected empty component list on total failure, got %d (%v)", len(components), components)
}
}
func TestHardwareGPSDeviceNotFound(t *testing.T) {
// Bug 5: When /dev/gps* doesn't exist, readlink -f still succeeds
// (returns canonical form of non-existent path). Verify the existence
// check prevents phantom GPS components.
runner := &testutil.MockRunner{Results: map[string][]byte{}, Errors: map[string]error{
"ls /dev/gps0": fmt.Errorf("No such file or directory"),
"ls /dev/gps1": fmt.Errorf("No such file or directory"),
"ls /dev/gps2": fmt.Errorf("No such file or directory"),
"ls /dev/gps3": fmt.Errorf("No such file or directory"),
}}
fs := &testutil.MockFileReader{Files: map[string][]byte{
"/run/system.json": []byte(`{}`),
}, Globs: map[string][]string{}}
components := collectHardware(t, newHardwareCollector(runner, fs))
for _, c := range components {
m, ok := c.(map[string]interface{})
if !ok {
continue
}
if m["class"] == "infix-hardware:gps" {
t.Fatalf("phantom GPS component should not exist when /dev/gps* missing: %v", m)
}
}
}
+208
View File
@@ -0,0 +1,208 @@
package collector
import (
"encoding/json"
"strconv"
"strings"
"syscall"
"time"
)
// LiveSystemState computes the on-demand portion of ietf-system:system-state.
// It reads uptime, current time, memory, load average from procfs and
// filesystem usage via statfs — all computed fresh on each call.
//
// Installer status is handled separately via MergeInstaller to avoid
// shallow-merge clobbering the boot-time software data.
func LiveSystemState(fs FileReader) json.RawMessage {
state := make(map[string]interface{})
if clock := liveClock(fs); len(clock) > 0 {
state["clock"] = clock
}
resource := make(map[string]interface{})
if mem := liveMemory(fs); len(mem) > 0 {
resource["memory"] = mem
}
if la := liveLoadAvg(fs); len(la) > 0 {
resource["load-average"] = la
}
if filesys := liveFilesystems(); len(filesys) > 0 {
resource["filesystem"] = filesys
}
if len(resource) > 0 {
state["infix-system:resource-usage"] = resource
}
data, err := json.Marshal(state)
if err != nil {
return nil
}
return data
}
// MergeInstaller reads the cached software data from the tree and
// overlays the live installer status into it, returning the merged
// infix-system:software object as a top-level system-state fragment.
func MergeInstaller(cached json.RawMessage, inst InstallerStatus) json.RawMessage {
if inst == nil {
return nil
}
installer := liveInstaller(inst)
if len(installer) == 0 {
return nil
}
var base map[string]json.RawMessage
if len(cached) > 0 {
json.Unmarshal(cached, &base)
}
if base == nil {
base = make(map[string]json.RawMessage)
}
sw := make(map[string]interface{})
if raw, ok := base["infix-system:software"]; ok {
json.Unmarshal(raw, &sw)
}
if sw == nil {
sw = make(map[string]interface{})
}
sw["installer"] = installer
swJSON, err := json.Marshal(sw)
if err != nil {
return nil
}
result := map[string]json.RawMessage{
"infix-system:software": swJSON,
}
out, err := json.Marshal(result)
if err != nil {
return nil
}
return out
}
func liveInstaller(inst InstallerStatus) map[string]interface{} {
op, lastErr, pct, msg, err := inst.GetInstallStatus()
if err != nil {
return nil
}
installer := make(map[string]interface{})
if op != "" {
installer["operation"] = op
}
if lastErr != "" {
installer["last-error"] = lastErr
}
if pct > 0 || msg != "" {
progress := make(map[string]interface{})
if pct > 0 {
progress["percentage"] = pct
}
if msg != "" {
progress["message"] = msg
}
installer["progress"] = progress
}
return installer
}
func liveClock(fs FileReader) map[string]interface{} {
data, err := fs.ReadFile("/proc/uptime")
if err != nil {
return nil
}
parts := strings.Fields(string(data))
if len(parts) < 1 {
return nil
}
upSec, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return nil
}
now := time.Now()
boot := now.Add(-time.Duration(upSec * float64(time.Second)))
return map[string]interface{}{
"current-datetime": yangDateTime(now),
"boot-datetime": yangDateTime(boot),
}
}
func liveMemory(fs FileReader) map[string]interface{} {
data, err := fs.ReadFile("/proc/meminfo")
if err != nil {
return nil
}
memFields := map[string]string{
"MemTotal": "total",
"MemFree": "free",
"MemAvailable": "available",
}
memory := make(map[string]interface{})
for _, line := range strings.Split(string(data), "\n") {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
jsonKey, ok := memFields[key]
if !ok {
continue
}
valStr := strings.TrimSpace(parts[1])
fields := strings.Fields(valStr)
if len(fields) < 1 {
continue
}
memory[jsonKey] = fields[0]
}
return memory
}
func liveLoadAvg(fs FileReader) map[string]interface{} {
data, err := fs.ReadFile("/proc/loadavg")
if err != nil {
return nil
}
fields := strings.Fields(string(data))
if len(fields) < 3 {
return nil
}
return map[string]interface{}{
"load-1min": fields[0],
"load-5min": fields[1],
"load-15min": fields[2],
}
}
func liveFilesystems() []interface{} {
mounts := []string{"/", "/var", "/cfg"}
var filesystems []interface{}
for _, mount := range mounts {
var stat syscall.Statfs_t
if err := syscall.Statfs(mount, &stat); err != nil {
continue
}
bsize := uint64(stat.Bsize)
sizeKB := (stat.Blocks * bsize) / 1024
availKB := (stat.Bavail * bsize) / 1024
usedKB := sizeKB - (stat.Bfree*bsize)/1024
filesystems = append(filesystems, map[string]interface{}{
"mount-point": mount,
"size": strconv.FormatUint(sizeKB, 10),
"used": strconv.FormatUint(usedKB, 10),
"available": strconv.FormatUint(availKB, 10),
})
}
return filesystems
}
+264
View File
@@ -0,0 +1,264 @@
package collector
import (
"encoding/json"
"testing"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/testutil"
)
func TestLiveClock(t *testing.T) {
fs := &testutil.MockFileReader{
Files: map[string][]byte{
"/proc/uptime": []byte("12345.67 23456.78\n"),
},
}
before := time.Now().Truncate(time.Second)
clock := liveClock(fs)
after := time.Now().Truncate(time.Second).Add(time.Second)
if clock == nil {
t.Fatal("expected non-nil clock")
}
cur, ok := clock["current-datetime"].(string)
if !ok || cur == "" {
t.Fatal("missing current-datetime")
}
parsed, err := time.Parse("2006-01-02T15:04:05-07:00", cur)
if err != nil {
t.Fatalf("invalid datetime format: %v", err)
}
if parsed.Before(before) || parsed.After(after) {
t.Fatalf("current-datetime %v not between %v and %v", parsed, before, after)
}
boot, ok := clock["boot-datetime"].(string)
if !ok || boot == "" {
t.Fatal("missing boot-datetime")
}
_, err = time.Parse("2006-01-02T15:04:05-07:00", boot)
if err != nil {
t.Fatalf("invalid boot-datetime format: %v", err)
}
}
func TestLiveClockMissingFile(t *testing.T) {
fs := &testutil.MockFileReader{
Files: map[string][]byte{},
}
if clock := liveClock(fs); clock != nil {
t.Fatalf("expected nil on missing /proc/uptime, got %v", clock)
}
}
func TestLiveMemory(t *testing.T) {
fs := &testutil.MockFileReader{
Files: map[string][]byte{
"/proc/meminfo": []byte("MemTotal: 1024000 kB\nMemFree: 512000 kB\nMemAvailable: 768000 kB\nBuffers: 64000 kB\n"),
},
}
mem := liveMemory(fs)
if mem == nil {
t.Fatal("expected non-nil memory")
}
checks := map[string]string{
"total": "1024000",
"free": "512000",
"available": "768000",
}
for key, expected := range checks {
got, ok := mem[key].(string)
if !ok || got != expected {
t.Fatalf("memory[%q]: expected %q, got %v", key, expected, mem[key])
}
}
if _, has := mem["Buffers"]; has {
t.Fatal("unexpected Buffers field in memory output")
}
}
func TestLiveMemoryMissingFile(t *testing.T) {
fs := &testutil.MockFileReader{
Files: map[string][]byte{},
}
if mem := liveMemory(fs); mem != nil {
t.Fatalf("expected nil on missing /proc/meminfo, got %v", mem)
}
}
func TestLiveLoadAvg(t *testing.T) {
fs := &testutil.MockFileReader{
Files: map[string][]byte{
"/proc/loadavg": []byte("0.42 0.31 0.15 2/123 4567\n"),
},
}
la := liveLoadAvg(fs)
if la == nil {
t.Fatal("expected non-nil load average")
}
checks := map[string]string{
"load-1min": "0.42",
"load-5min": "0.31",
"load-15min": "0.15",
}
for key, expected := range checks {
got, ok := la[key].(string)
if !ok || got != expected {
t.Fatalf("load-average[%q]: expected %q, got %v", key, expected, la[key])
}
}
}
func TestLiveLoadAvgMissingFile(t *testing.T) {
fs := &testutil.MockFileReader{
Files: map[string][]byte{},
}
if la := liveLoadAvg(fs); la != nil {
t.Fatalf("expected nil on missing /proc/loadavg, got %v", la)
}
}
func TestLiveSystemState(t *testing.T) {
fs := &testutil.MockFileReader{
Files: map[string][]byte{
"/proc/uptime": []byte("100.0 200.0\n"),
"/proc/meminfo": []byte("MemTotal: 2048000 kB\nMemFree: 1024000 kB\nMemAvailable: 1536000 kB\n"),
"/proc/loadavg": []byte("1.00 0.50 0.25 3/200 9999\n"),
},
}
raw := LiveSystemState(fs)
if raw == nil {
t.Fatal("expected non-nil LiveSystemState output")
}
var state map[string]interface{}
if err := json.Unmarshal(raw, &state); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := state["clock"]; !ok {
t.Fatal("missing clock in live state")
}
resource, ok := state["infix-system:resource-usage"].(map[string]interface{})
if !ok {
t.Fatal("missing infix-system:resource-usage in live state")
}
if _, ok := resource["memory"]; !ok {
t.Fatal("missing memory in resource-usage")
}
if _, ok := resource["load-average"]; !ok {
t.Fatal("missing load-average in resource-usage")
}
}
func TestLiveSystemStatePartialFailure(t *testing.T) {
fs := &testutil.MockFileReader{
Files: map[string][]byte{
"/proc/loadavg": []byte("0.10 0.20 0.30 1/50 1234\n"),
},
}
raw := LiveSystemState(fs)
if raw == nil {
t.Fatal("expected non-nil even with partial data")
}
var state map[string]interface{}
json.Unmarshal(raw, &state)
if _, ok := state["clock"]; ok {
t.Fatal("clock should be absent when /proc/uptime is missing")
}
resource := state["infix-system:resource-usage"].(map[string]interface{})
if _, ok := resource["memory"]; ok {
t.Fatal("memory should be absent when /proc/meminfo is missing")
}
if _, ok := resource["load-average"]; !ok {
t.Fatal("load-average should be present")
}
}
type mockInstaller struct {
op, lastErr, msg string
pct int
err error
}
func (m *mockInstaller) GetInstallStatus() (string, string, int, string, error) {
return m.op, m.lastErr, m.pct, m.msg, m.err
}
func TestMergeInstaller(t *testing.T) {
cached := json.RawMessage(`{"infix-system:software":{"compatible":"infix-x86_64","booted":{"slot":"rootfs.0"}}}`)
inst := &mockInstaller{op: "installing", pct: 45, msg: "Writing rootfs"}
raw := MergeInstaller(cached, inst)
if raw == nil {
t.Fatal("expected non-nil")
}
var result map[string]json.RawMessage
json.Unmarshal(raw, &result)
var sw map[string]interface{}
json.Unmarshal(result["infix-system:software"], &sw)
if sw == nil {
t.Fatal("missing infix-system:software")
}
if sw["compatible"] != "infix-x86_64" {
t.Fatalf("cached 'compatible' was lost: %v", sw["compatible"])
}
if sw["booted"] == nil {
t.Fatal("cached 'booted' was lost")
}
installer, ok := sw["installer"].(map[string]interface{})
if !ok {
t.Fatal("missing installer")
}
if installer["operation"] != "installing" {
t.Fatalf("operation = %v, want 'installing'", installer["operation"])
}
progress, ok := installer["progress"].(map[string]interface{})
if !ok {
t.Fatal("missing progress")
}
if toInt(progress["percentage"]) != 45 {
t.Fatalf("percentage = %v, want 45", progress["percentage"])
}
if progress["message"] != "Writing rootfs" {
t.Fatalf("message = %v", progress["message"])
}
}
func TestMergeInstallerNilCached(t *testing.T) {
inst := &mockInstaller{op: "idle"}
raw := MergeInstaller(nil, inst)
if raw == nil {
t.Fatal("expected non-nil even with nil cached")
}
var result map[string]json.RawMessage
json.Unmarshal(raw, &result)
var sw map[string]interface{}
json.Unmarshal(result["infix-system:software"], &sw)
if sw["installer"] == nil {
t.Fatal("missing installer")
}
}
func TestMergeInstallerNilInst(t *testing.T) {
cached := json.RawMessage(`{"infix-system:software":{"compatible":"infix-x86_64"}}`)
if raw := MergeInstaller(cached, nil); raw != nil {
t.Fatalf("expected nil with nil installer, got %s", raw)
}
}
+542
View File
@@ -0,0 +1,542 @@
package collector
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
// NTPCollector gathers ietf-ntp operational data by running chronyc
// commands (sources, sourcestats, tracking, serverstats) and ss to
// detect the NTP listening port.
type NTPCollector struct {
cmd CommandRunner
interval time.Duration
}
// NewNTPCollector creates an NTPCollector with the given dependencies.
func NewNTPCollector(cmd CommandRunner, interval time.Duration) *NTPCollector {
return &NTPCollector{cmd: cmd, interval: interval}
}
// Name implements Collector.
func (c *NTPCollector) Name() string { return "ntp" }
// Interval implements Collector.
func (c *NTPCollector) Interval() time.Duration { return c.interval }
// Collect implements Collector. It produces two tree keys:
// - "ietf-ntp:ntp" — associations, clock state, server status, and
// server statistics (RFC 9249).
// - "ietf-system:system-state" — merged infix-system:ntp/sources/source
// list with address, mode, state, stratum and poll for each chrony
// source (Infix augmentation of ietf-system).
func (c *NTPCollector) Collect(ctx context.Context, t *tree.Tree) error {
// Run chronyc sources once and share between addAssociations and addSources.
sourcesOut, _ := c.cmd.Run(ctx, "chronyc", "-c", "sources")
ntp := make(map[string]interface{})
c.addAssociations(ctx, ntp, sourcesOut)
c.addClockState(ctx, ntp)
c.addServerStatus(ctx, ntp)
c.addServerStats(ctx, ntp)
if len(ntp) > 0 {
if data, err := json.Marshal(ntp); err == nil {
t.Set("ietf-ntp:ntp", data)
}
} else {
// chronyd is not running (NTP unconfigured, or disabled by a
// config change). Drop the key so data from a previous run does
// not linger -- yangerd outlives config resets, so stale state
// would otherwise survive until restart.
t.Delete("ietf-ntp:ntp")
}
// Always refresh the Infix NTP sources under system-state, even when
// empty. Otherwise a source that disappears from chrony (e.g. a DHCP
// lease without option 42, or NTP turned off) lingers as stale
// operational data -- a phantom "selected" server chronyc no longer
// reports. Merge only overwrites the keys it is given, so we must
// hand it an empty source list to clear a previously-populated one.
sources := c.addSources(sourcesOut)
if sources == nil {
sources = map[string]interface{}{
"sources": map[string]interface{}{
"source": []interface{}{},
},
}
}
if data, err := json.Marshal(map[string]interface{}{
"infix-system:ntp": sources,
}); err == nil {
t.Merge("ietf-system:system-state", data)
}
return nil
}
// addAssociations parses chronyc sources and sourcestats CSV output
// into the associations/association list.
//
// chronyc -c sources format (comma-separated):
//
// [0] Mode: ^ server, = peer, # refclock (skipped)
// [1] State: * selected, + candidate, - outlier, ? unusable, x falseticker, ~ unstable
// [2] Address (IP)
// [3] Stratum
// [4] Poll interval (log2 seconds)
// [5] Reach (octal reachability register)
// [6] LastRx (seconds since last response)
// [7] Last offset (seconds)
// [8] Offset at last update (seconds)
// [9] Error estimate (seconds)
//
// chronyc -c sourcestats format:
//
// [0] Address
// [1] NP
// [2] NR
// [3] Span
// [4] Frequency (ppm)
// [5] Freq Skew (ppm)
// [6] Offset (seconds)
// [7] Std Dev (seconds)
func (c *NTPCollector) addAssociations(ctx context.Context, ntp map[string]interface{}, sourcesOut []byte) {
if len(sourcesOut) == 0 {
return
}
// Build stats map from sourcestats for offset/dispersion
statsMap := make(map[string]map[string]string)
statsOut, err := c.cmd.Run(ctx, "chronyc", "-c", "sourcestats")
if err == nil {
for _, line := range splitLines(string(statsOut)) {
parts := strings.Split(line, ",")
if len(parts) >= 8 {
statsMap[parts[0]] = map[string]string{
"offset": parts[6],
"std_dev": parts[7],
}
}
}
}
modeMap := map[string]string{
"^": "ietf-ntp:client",
"=": "ietf-ntp:active",
"#": "ietf-ntp:broadcast-client",
}
var associations []interface{}
for _, line := range splitLines(string(sourcesOut)) {
parts := strings.Split(line, ",")
if len(parts) < 10 {
continue
}
modeIndicator := parts[0]
// Skip reference clocks — they have names like "GPS", not IP addresses
if modeIndicator == "#" {
continue
}
stateIndicator := parts[1]
address := parts[2]
stratum, err := strconv.Atoi(parts[3])
if err != nil {
continue
}
// YANG requires stratum 1..16
if stratum < 1 || stratum > 16 {
continue
}
assoc := map[string]interface{}{
"address": address,
"local-mode": modeMap[modeIndicator],
"isconfigured": true,
"stratum": stratum,
}
if assoc["local-mode"] == nil {
assoc["local-mode"] = "ietf-ntp:client"
}
// Current sync source
if stateIndicator == "*" {
assoc["prefer"] = true
}
// Reachability register (octal → decimal)
if reach, err := strconv.ParseInt(parts[5], 8, 32); err == nil {
assoc["reach"] = int(reach)
}
// Poll interval (log2 seconds)
if poll, err := strconv.Atoi(parts[4]); err == nil {
assoc["poll"] = poll
}
// Time since last packet
if now, err := strconv.Atoi(parts[6]); err == nil {
assoc["now"] = now
}
// Offset: prefer sourcestats if available, else sources[7]
// Convert seconds → milliseconds with 3 fraction digits
if stats, ok := statsMap[address]; ok {
if offsetSec, err := strconv.ParseFloat(stats["offset"], 64); err == nil {
assoc["offset"] = fmt.Sprintf("%.3f", offsetSec*1000.0)
}
} else if offsetSec, err := strconv.ParseFloat(parts[7], 64); err == nil {
assoc["offset"] = fmt.Sprintf("%.3f", offsetSec*1000.0)
}
// Delay: error estimate from sources[9], seconds → milliseconds
if delaySec, err := strconv.ParseFloat(parts[9], 64); err == nil {
assoc["delay"] = fmt.Sprintf("%.3f", math.Abs(delaySec)*1000.0)
}
// Dispersion: std_dev from sourcestats, seconds → milliseconds
if stats, ok := statsMap[address]; ok {
if dispSec, err := strconv.ParseFloat(stats["std_dev"], 64); err == nil {
assoc["dispersion"] = fmt.Sprintf("%.3f", dispSec*1000.0)
}
}
associations = append(associations, assoc)
}
if len(associations) > 0 {
ntp["associations"] = map[string]interface{}{
"association": associations,
}
}
}
// sourceStateMap maps chronyc source-state indicators to YANG
// infix-system source-state enum values.
var sourceStateMap = map[string]string{
"*": "selected",
"+": "candidate",
"-": "outlier",
"?": "unusable",
"x": "falseticker",
"~": "unstable",
}
// sourceModeMap maps chronyc mode indicators to YANG
// infix-system source-mode enum values.
var sourceModeMap = map[string]string{
"^": "server",
"=": "peer",
"#": "local-clock",
}
// addSources builds the infix-system:ntp/sources/source list from
// chronyc -c sources output. Reference clocks (mode #) and sources
// with invalid stratum are skipped, matching the Python yanger
// ietf_system.py add_ntp() behaviour.
func (c *NTPCollector) addSources(sourcesOut []byte) map[string]interface{} {
if len(sourcesOut) == 0 {
return nil
}
var sources []interface{}
for _, line := range splitLines(string(sourcesOut)) {
parts := strings.Split(line, ",")
if len(parts) < 10 {
continue
}
modeIndicator := parts[0]
if modeIndicator == "#" {
continue
}
stratum, err := strconv.Atoi(parts[3])
if err != nil || stratum > 16 {
continue
}
mode := sourceModeMap[modeIndicator]
if mode == "" {
mode = "server"
}
state := sourceStateMap[parts[1]]
if state == "" {
continue
}
src := map[string]interface{}{
"address": parts[2],
"mode": mode,
"state": state,
"stratum": stratum,
}
if poll, err := strconv.Atoi(parts[4]); err == nil {
src["poll"] = poll
}
sources = append(sources, src)
}
if len(sources) == 0 {
return nil
}
return map[string]interface{}{
"sources": map[string]interface{}{
"source": sources,
},
}
}
// addClockState parses chronyc tracking CSV output into the clock-state
// container.
//
// chronyc -c tracking format (comma-separated):
//
// [0] Ref-ID (hex IP, e.g. "C0A80101")
// [1] Ref-ID name (e.g. "router.local")
// [2] Stratum
// [3] Ref time (seconds since epoch)
// [4] System time offset (seconds)
// [5] Last offset (seconds)
// [6] RMS offset (seconds)
// [7] Frequency (ppm)
// [8] Residual frequency (ppm)
// [9] Skew (ppm)
// [10] Root delay (seconds)
// [11] Root dispersion (seconds)
// [12] Update interval (seconds)
// [13] Leap status (e.g. "Normal", "Not synchronised")
func (c *NTPCollector) addClockState(ctx context.Context, ntp map[string]interface{}) {
out, err := c.cmd.Run(ctx, "chronyc", "-c", "tracking")
if err != nil || len(out) == 0 {
return
}
lines := splitLines(string(out))
if len(lines) == 0 {
return
}
parts := strings.Split(lines[0], ",")
if len(parts) < 14 {
return
}
ss := make(map[string]interface{})
// Stratum: chronyd uses 0 for "not synchronized", YANG requires 1-16
stratumRaw, _ := strconv.Atoi(parts[2])
stratum := stratumRaw
if stratum == 0 {
stratum = 16
}
if stratum == 16 {
ss["clock-state"] = "ietf-ntp:unsynchronized"
} else {
ss["clock-state"] = "ietf-ntp:synchronized"
}
ss["clock-stratum"] = stratum
// Reference ID
refidIP := parts[0]
refidName := parts[1]
if refidName != "" {
// NTP refids are always 4 bytes; pad/truncate to exactly 4 chars
padded := refidName + " "
ss["clock-refid"] = padded[:4]
} else if len(refidIP) == 8 {
a, e1 := strconv.ParseInt(refidIP[0:2], 16, 32)
b, e2 := strconv.ParseInt(refidIP[2:4], 16, 32)
cv, e3 := strconv.ParseInt(refidIP[4:6], 16, 32)
d, e4 := strconv.ParseInt(refidIP[6:8], 16, 32)
if e1 == nil && e2 == nil && e3 == nil && e4 == nil {
ss["clock-refid"] = fmt.Sprintf("%d.%d.%d.%d", a, b, cv, d)
} else {
ss["clock-refid"] = refidIP
}
} else if refidIP != "" {
ss["clock-refid"] = refidIP
} else {
ss["clock-refid"] = "0.0.0.0"
}
// Frequencies (ppm → Hz with nominal 1GHz)
if freqPPM, err := strconv.ParseFloat(parts[7], 64); err == nil {
nominal := 1000000000.0
actual := nominal * (1.0 + freqPPM/1000000.0)
ss["nominal-freq"] = fmt.Sprintf("%.4f", nominal)
ss["actual-freq"] = fmt.Sprintf("%.4f", actual)
}
// Clock precision (fixed estimate, ~1µs)
ss["clock-precision"] = -20
// Clock offset (system-time column[4], seconds → milliseconds)
if offsetSec, err := strconv.ParseFloat(parts[4], 64); err == nil {
ss["clock-offset"] = fmt.Sprintf("%.3f", offsetSec*1000.0)
}
// Root delay (seconds → milliseconds)
if rootDelay, err := strconv.ParseFloat(parts[10], 64); err == nil {
ss["root-delay"] = fmt.Sprintf("%.3f", rootDelay*1000.0)
}
// Root dispersion (seconds → milliseconds)
if rootDisp, err := strconv.ParseFloat(parts[11], 64); err == nil {
ss["root-dispersion"] = fmt.Sprintf("%.3f", rootDisp*1000.0)
}
// Reference time (epoch seconds → ISO 8601)
if refTime, err := strconv.ParseFloat(parts[3], 64); err == nil && refTime > 0 {
sec := int64(refTime)
nsec := int64((refTime - float64(sec)) * 1e9)
t := time.Unix(sec, nsec).UTC()
ss["reference-time"] = t.Format("2006-01-02T15:04:05.000") + "Z"
}
// Sync state based on leap status
leapStatus := strings.TrimSpace(parts[13])
if leapStatus == "Not synchronised" || stratum == 16 {
ss["sync-state"] = "ietf-ntp:clock-never-set"
} else {
ss["sync-state"] = "ietf-ntp:clock-synchronized"
}
// Infix augmentations
if lastOffset, err := strconv.ParseFloat(parts[5], 64); err == nil {
ss["infix-ntp:last-offset"] = fmt.Sprintf("%.9f", lastOffset)
}
if rmsOffset, err := strconv.ParseFloat(parts[6], 64); err == nil {
ss["infix-ntp:rms-offset"] = fmt.Sprintf("%.9f", rmsOffset)
}
if residualFreq, err := strconv.ParseFloat(parts[8], 64); err == nil {
ss["infix-ntp:residual-freq"] = fmt.Sprintf("%.3f", residualFreq)
}
if skew, err := strconv.ParseFloat(parts[9], 64); err == nil {
ss["infix-ntp:skew"] = fmt.Sprintf("%.3f", skew)
}
if updateInterval, err := strconv.ParseFloat(parts[12], 64); err == nil {
ss["infix-ntp:update-interval"] = fmt.Sprintf("%.1f", updateInterval)
}
ntp["clock-state"] = map[string]interface{}{
"system-status": ss,
}
}
// addServerStatus adds the refclock-master stratum and listening port.
// Must be called after addClockState so clock-state is available.
func (c *NTPCollector) addServerStatus(ctx context.Context, ntp map[string]interface{}) {
// Reuse stratum from clock-state if already populated
if cs, ok := ntp["clock-state"].(map[string]interface{}); ok {
if ss, ok := cs["system-status"].(map[string]interface{}); ok {
if stratum, ok := ss["clock-stratum"]; ok {
ntp["refclock-master"] = map[string]interface{}{
"master-stratum": stratum,
}
}
}
}
// Detect NTP listening port via ss
ssOut, err := c.cmd.Run(ctx, "ss", "-ulnp")
if err != nil {
return
}
for _, line := range splitLines(string(ssOut)) {
if !strings.Contains(line, "chronyd") {
continue
}
// Skip loopback (command socket)
if strings.Contains(line, "127.0.0.1") || strings.Contains(line, "[::1]") {
continue
}
fields := strings.Fields(line)
if len(fields) >= 5 {
localAddr := fields[3]
idx := strings.LastIndex(localAddr, ":")
if idx >= 0 {
portStr := localAddr[idx+1:]
if port, err := strconv.Atoi(portStr); err == nil {
ntp["port"] = port
break
}
}
}
}
}
// addServerStats parses chronyc serverstats CSV into ntp-statistics.
//
// chronyc -c serverstats format:
//
// [0] NTP packets received
// [1] NTP packets dropped
// [2] Cmd packets received
// [3] Cmd packets dropped
// [4] Client log size active
// [5] Client log memory
// [6] Rate limit drops
// [7] NTP packets sent
// [8] NTP packets send fail
func (c *NTPCollector) addServerStats(ctx context.Context, ntp map[string]interface{}) {
out, err := c.cmd.Run(ctx, "chronyc", "-c", "serverstats")
if err != nil || len(out) == 0 {
return
}
lines := splitLines(string(out))
if len(lines) == 0 {
return
}
parts := strings.Split(lines[0], ",")
if len(parts) < 9 {
return
}
stats := make(map[string]interface{})
if v, err := strconv.Atoi(parts[0]); err == nil {
stats["packet-received"] = v
}
if v, err := strconv.Atoi(parts[1]); err == nil {
stats["packet-dropped"] = v
}
if v, err := strconv.Atoi(parts[7]); err == nil {
stats["packet-sent"] = v
}
if v, err := strconv.Atoi(parts[8]); err == nil {
stats["packet-sent-fail"] = v
}
if len(stats) > 0 {
ntp["ntp-statistics"] = stats
}
}
// splitLines splits text into non-empty lines.
func splitLines(text string) []string {
var lines []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line != "" {
lines = append(lines, line)
}
}
return lines
}
+494
View File
@@ -0,0 +1,494 @@
package collector
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/testutil"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
const testChronycSources = `^,*,10.0.0.1,2,6,377,32,+0.000123,,0.000456
^,+,10.0.0.2,3,7,377,64,-0.000789,,0.001234
=,-,10.0.0.3,4,6,177,128,+0.001500,,0.002000
#,,GPS,1,4,377,16,+0.000001,,0.000010
^,?,10.0.0.4,0,6,0,0,+0.000000,,0.000000`
const testChronycSourcestats = `10.0.0.1,15,8,256,0.001,-0.002,+0.000050,0.000100
10.0.0.2,12,6,128,-0.005,0.003,-0.000300,0.000200
10.0.0.3,8,4,64,0.010,-0.008,+0.001000,0.000500`
const testChronycTracking = `C0A80001,router.local,2,1700000000.123,0.000045,-0.000012,0.000025,-1.500,0.003,0.050,0.004500,0.001200,64.0,Normal`
const testChronycTrackingUnsync = `00000000,,0,0.000,0.000000,0.000000,0.000000,0.000,0.000,0.000,0.000000,0.000000,0.0,Not synchronised`
const testChronycServerstats = `1000,5,200,3,1,8192,2,950,10`
const testSSOutput = `State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
UNCONN 0 0 0.0.0.0:123 0.0.0.0:* users:(("chronyd",pid=5441,fd=5))
UNCONN 0 0 127.0.0.1:323 0.0.0.0:* users:(("chronyd",pid=5441,fd=1))
`
func newNTPCollector(runner *testutil.MockRunner) *NTPCollector {
return NewNTPCollector(runner, 60*time.Second)
}
func ntpCollect(t *testing.T, runner *testutil.MockRunner) (map[string]interface{}, *tree.Tree) {
t.Helper()
c := newNTPCollector(runner)
tr := tree.New()
if err := c.Collect(context.Background(), tr); err != nil {
t.Fatalf("Collect failed: %v", err)
}
raw := tr.Get("ietf-ntp:ntp")
if raw == nil {
t.Fatal("missing ietf-ntp:ntp in tree")
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal ntp: %v", err)
}
return out, tr
}
func fullNTPRunner() *testutil.MockRunner {
return &testutil.MockRunner{
Results: map[string][]byte{
"chronyc -c sources": []byte(testChronycSources),
"chronyc -c sourcestats": []byte(testChronycSourcestats),
"chronyc -c tracking": []byte(testChronycTracking),
"chronyc -c serverstats": []byte(testChronycServerstats),
"ss -ulnp": []byte(testSSOutput),
},
Errors: map[string]error{},
}
}
func TestNTPCollectorNameAndInterval(t *testing.T) {
c := newNTPCollector(fullNTPRunner())
if c.Name() != "ntp" {
t.Fatalf("expected name 'ntp', got %q", c.Name())
}
if c.Interval() != 60*time.Second {
t.Fatalf("expected interval 60s, got %v", c.Interval())
}
}
func TestNTPAssociations(t *testing.T) {
out, _ := ntpCollect(t, fullNTPRunner())
assocContainer := out["associations"].(map[string]interface{})
assocs := assocContainer["association"].([]interface{})
// 5 sources minus GPS refclock (#) minus stratum-0 (10.0.0.4) = 3
if len(assocs) != 3 {
t.Fatalf("expected 3 associations (refclock+stratum0 filtered), got %d", len(assocs))
}
byAddr := make(map[string]map[string]interface{})
for _, a := range assocs {
am := a.(map[string]interface{})
byAddr[am["address"].(string)] = am
}
// 10.0.0.1: selected server (*), stratum 2
a1 := byAddr["10.0.0.1"]
if a1 == nil {
t.Fatal("missing association for 10.0.0.1")
}
if a1["local-mode"] != "ietf-ntp:client" {
t.Fatalf("10.0.0.1 mode: expected ietf-ntp:client, got %v", a1["local-mode"])
}
if a1["prefer"] != true {
t.Fatalf("10.0.0.1 should be preferred (selected source)")
}
if toInt(a1["stratum"]) != 2 {
t.Fatalf("10.0.0.1 stratum: expected 2, got %v", a1["stratum"])
}
// Reach: 377 octal = 255 decimal
if toInt(a1["reach"]) != 255 {
t.Fatalf("10.0.0.1 reach: expected 255, got %v", a1["reach"])
}
// Offset should come from sourcestats (0.000050s → 0.050ms)
if a1["offset"] != "0.050" {
t.Fatalf("10.0.0.1 offset: expected '0.050', got %v", a1["offset"])
}
// Dispersion from sourcestats std_dev (0.000100s → 0.100ms)
if a1["dispersion"] != "0.100" {
t.Fatalf("10.0.0.1 dispersion: expected '0.100', got %v", a1["dispersion"])
}
// 10.0.0.3: peer mode (=)
a3 := byAddr["10.0.0.3"]
if a3 == nil {
t.Fatal("missing association for 10.0.0.3")
}
if a3["local-mode"] != "ietf-ntp:active" {
t.Fatalf("10.0.0.3 mode: expected ietf-ntp:active, got %v", a3["local-mode"])
}
// Should NOT be preferred (state is -)
if _, hasPrefer := a3["prefer"]; hasPrefer {
t.Fatal("10.0.0.3 should not be preferred")
}
}
func TestNTPSources(t *testing.T) {
_, tr := ntpCollect(t, fullNTPRunner())
raw := tr.Get("ietf-system:system-state")
if raw == nil {
t.Fatal("missing ietf-system:system-state in tree")
}
var state map[string]interface{}
if err := json.Unmarshal(raw, &state); err != nil {
t.Fatalf("unmarshal system-state: %v", err)
}
ntpData, ok := state["infix-system:ntp"].(map[string]interface{})
if !ok {
t.Fatal("missing infix-system:ntp in system-state")
}
sourcesContainer, ok := ntpData["sources"].(map[string]interface{})
if !ok {
t.Fatal("missing sources in infix-system:ntp")
}
sources, ok := sourcesContainer["source"].([]interface{})
if !ok {
t.Fatal("missing source list in sources")
}
// 5 sources minus GPS refclock (#) = 4 (stratum 0 is kept)
if len(sources) != 4 {
t.Fatalf("expected 4 sources, got %d", len(sources))
}
byAddr := make(map[string]map[string]interface{})
for _, s := range sources {
sm := s.(map[string]interface{})
byAddr[sm["address"].(string)] = sm
}
// 10.0.0.1: selected server
s1 := byAddr["10.0.0.1"]
if s1 == nil {
t.Fatal("missing source 10.0.0.1")
}
if s1["state"] != "selected" {
t.Fatalf("10.0.0.1 state: expected selected, got %v", s1["state"])
}
if s1["mode"] != "server" {
t.Fatalf("10.0.0.1 mode: expected server, got %v", s1["mode"])
}
if toInt(s1["stratum"]) != 2 {
t.Fatalf("10.0.0.1 stratum: expected 2, got %v", s1["stratum"])
}
if toInt(s1["poll"]) != 6 {
t.Fatalf("10.0.0.1 poll: expected 6, got %v", s1["poll"])
}
// 10.0.0.2: candidate server
s2 := byAddr["10.0.0.2"]
if s2 == nil {
t.Fatal("missing source 10.0.0.2")
}
if s2["state"] != "candidate" {
t.Fatalf("10.0.0.2 state: expected candidate, got %v", s2["state"])
}
if s2["mode"] != "server" {
t.Fatalf("10.0.0.2 mode: expected server, got %v", s2["mode"])
}
// 10.0.0.3: outlier peer
s3 := byAddr["10.0.0.3"]
if s3 == nil {
t.Fatal("missing source 10.0.0.3")
}
if s3["state"] != "outlier" {
t.Fatalf("10.0.0.3 state: expected outlier, got %v", s3["state"])
}
if s3["mode"] != "peer" {
t.Fatalf("10.0.0.3 mode: expected peer, got %v", s3["mode"])
}
// 10.0.0.4: unreachable server (stratum 0)
s4 := byAddr["10.0.0.4"]
if s4 == nil {
t.Fatal("missing source 10.0.0.4")
}
if s4["state"] != "unusable" {
t.Fatalf("10.0.0.4 state: expected unusable, got %v", s4["state"])
}
if toInt(s4["stratum"]) != 0 {
t.Fatalf("10.0.0.4 stratum: expected 0, got %v", s4["stratum"])
}
}
// ntpSourceCount returns the number of infix-system:ntp sources in the
// system-state tree key, failing the test if the subtree is missing.
func ntpSourceCount(t *testing.T, tr *tree.Tree) int {
t.Helper()
raw := tr.Get("ietf-system:system-state")
if raw == nil {
t.Fatal("system-state not set")
}
var data map[string]json.RawMessage
if err := json.Unmarshal(raw, &data); err != nil {
t.Fatalf("unmarshal system-state: %v", err)
}
ntpRaw, ok := data["infix-system:ntp"]
if !ok {
t.Fatal("infix-system:ntp not present")
}
var ntp struct {
Sources struct {
Source []json.RawMessage `json:"source"`
} `json:"sources"`
}
if err := json.Unmarshal(ntpRaw, &ntp); err != nil {
t.Fatalf("unmarshal infix-system:ntp: %v", err)
}
return len(ntp.Sources.Source)
}
func TestNTPSourcesEmpty(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"chronyc -c tracking": []byte(testChronycTracking),
},
Errors: map[string]error{},
}
c := newNTPCollector(runner)
tr := tree.New()
c.Collect(context.Background(), tr)
// With no chrony sources the collector must still write an empty
// source list, so a previously-reported source cannot linger as
// stale operational data.
if n := ntpSourceCount(t, tr); n != 0 {
t.Fatalf("expected empty NTP source list, got %d", n)
}
}
// When chronyd stops (NTP disabled via config reset), the whole
// ietf-ntp:ntp key must disappear -- yangerd outlives config resets, so
// a key that is only ever Set when non-empty would keep stale data from
// a previous run forever.
func TestNTPTreeKeyRemovedWhenChronydStops(t *testing.T) {
tr := tree.New()
running := &testutil.MockRunner{
Results: map[string][]byte{
"chronyc -c sources": []byte(testChronycSources),
"chronyc -c tracking": []byte(testChronycTracking),
},
Errors: map[string]error{},
}
newNTPCollector(running).Collect(context.Background(), tr)
if tr.Get("ietf-ntp:ntp") == nil {
t.Fatal("expected ietf-ntp:ntp after first poll")
}
stopped := &testutil.MockRunner{
Results: map[string][]byte{},
Errors: map[string]error{},
}
newNTPCollector(stopped).Collect(context.Background(), tr)
if data := tr.Get("ietf-ntp:ntp"); data != nil {
t.Fatalf("stale ietf-ntp:ntp survived chronyd stop: %s", data)
}
}
// A source that disappears from chrony (e.g. a DHCP NTP server that is no
// longer offered) must be cleared from operational, not left stale.
func TestNTPSourcesClearedWhenGone(t *testing.T) {
tr := tree.New()
withSources := &testutil.MockRunner{
Results: map[string][]byte{
"chronyc -c sources": []byte(testChronycSources),
"chronyc -c tracking": []byte(testChronycTracking),
},
Errors: map[string]error{},
}
newNTPCollector(withSources).Collect(context.Background(), tr)
if ntpSourceCount(t, tr) == 0 {
t.Fatal("expected NTP sources after first poll")
}
noSources := &testutil.MockRunner{
Results: map[string][]byte{
"chronyc -c tracking": []byte(testChronycTracking),
},
Errors: map[string]error{},
}
newNTPCollector(noSources).Collect(context.Background(), tr)
if n := ntpSourceCount(t, tr); n != 0 {
t.Fatalf("stale NTP sources not cleared: got %d, want 0", n)
}
}
func TestNTPClockStateSynchronized(t *testing.T) {
out, _ := ntpCollect(t, fullNTPRunner())
cs := out["clock-state"].(map[string]interface{})
ss := cs["system-status"].(map[string]interface{})
if ss["clock-state"] != "ietf-ntp:synchronized" {
t.Fatalf("clock-state: expected synchronized, got %v", ss["clock-state"])
}
if toInt(ss["clock-stratum"]) != 2 {
t.Fatalf("clock-stratum: expected 2, got %v", ss["clock-stratum"])
}
// refid from name "router.local" → padded/truncated to 4 chars: "rout"
if ss["clock-refid"] != "rout" {
t.Fatalf("clock-refid: expected 'rout', got %v", ss["clock-refid"])
}
if ss["sync-state"] != "ietf-ntp:clock-synchronized" {
t.Fatalf("sync-state: expected clock-synchronized, got %v", ss["sync-state"])
}
if toInt(ss["clock-precision"]) != -20 {
t.Fatalf("clock-precision: expected -20, got %v", ss["clock-precision"])
}
// Verify nominal/actual freq strings
if ss["nominal-freq"] != "1000000000.0000" {
t.Fatalf("nominal-freq: expected '1000000000.0000', got %v", ss["nominal-freq"])
}
// Infix augmentations
if ss["infix-ntp:update-interval"] != "64.0" {
t.Fatalf("update-interval: expected '64.0', got %v", ss["infix-ntp:update-interval"])
}
// Reference time should be an ISO timestamp
refTime, ok := ss["reference-time"].(string)
if !ok || !strings.HasPrefix(refTime, "2023-") {
t.Fatalf("reference-time should be 2023-* ISO timestamp, got %v", ss["reference-time"])
}
}
func TestNTPClockStateUnsynchronized(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"chronyc -c tracking": []byte(testChronycTrackingUnsync),
},
Errors: map[string]error{},
}
c := newNTPCollector(runner)
tr := tree.New()
c.Collect(context.Background(), tr)
raw := tr.Get("ietf-ntp:ntp")
if raw == nil {
t.Fatal("expected ietf-ntp:ntp even when unsynchronized")
}
var out map[string]interface{}
json.Unmarshal(raw, &out)
cs := out["clock-state"].(map[string]interface{})
ss := cs["system-status"].(map[string]interface{})
if ss["clock-state"] != "ietf-ntp:unsynchronized" {
t.Fatalf("clock-state: expected unsynchronized, got %v", ss["clock-state"])
}
// Stratum 0 → 16
if toInt(ss["clock-stratum"]) != 16 {
t.Fatalf("clock-stratum: expected 16 (mapped from 0), got %v", ss["clock-stratum"])
}
if ss["sync-state"] != "ietf-ntp:clock-never-set" {
t.Fatalf("sync-state: expected clock-never-set, got %v", ss["sync-state"])
}
}
func TestNTPServerPort(t *testing.T) {
out, _ := ntpCollect(t, fullNTPRunner())
// Should find port 123 from the non-loopback ss line
if toInt(out["port"]) != 123 {
t.Fatalf("port: expected 123, got %v", out["port"])
}
}
func TestNTPRefclockMaster(t *testing.T) {
out, _ := ntpCollect(t, fullNTPRunner())
master := out["refclock-master"].(map[string]interface{})
if toInt(master["master-stratum"]) != 2 {
t.Fatalf("master-stratum: expected 2, got %v", master["master-stratum"])
}
}
func TestNTPServerStats(t *testing.T) {
out, _ := ntpCollect(t, fullNTPRunner())
stats := out["ntp-statistics"].(map[string]interface{})
if toInt(stats["packet-received"]) != 1000 {
t.Fatalf("packet-received: expected 1000, got %v", stats["packet-received"])
}
if toInt(stats["packet-dropped"]) != 5 {
t.Fatalf("packet-dropped: expected 5, got %v", stats["packet-dropped"])
}
if toInt(stats["packet-sent"]) != 950 {
t.Fatalf("packet-sent: expected 950, got %v", stats["packet-sent"])
}
if toInt(stats["packet-sent-fail"]) != 10 {
t.Fatalf("packet-sent-fail: expected 10, got %v", stats["packet-sent-fail"])
}
}
func TestNTPAllCommandsFail(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{},
Errors: map[string]error{},
}
c := newNTPCollector(runner)
tr := tree.New()
err := c.Collect(context.Background(), tr)
if err != nil {
t.Fatalf("Collect should not error when chronyc unavailable: %v", err)
}
if tr.Get("ietf-ntp:ntp") != nil {
t.Fatal("should not set ietf-ntp:ntp when nothing to report")
}
}
func TestNTPRefidHexToIPv4(t *testing.T) {
// When refid name is empty, hex ref-ID should be converted to dotted notation
runner := &testutil.MockRunner{
Results: map[string][]byte{
"chronyc -c tracking": []byte("C0A80101,,2,1700000000.0,0.000001,0.000000,0.000000,-1.0,0.0,0.0,0.001,0.001,64.0,Normal"),
},
Errors: map[string]error{},
}
c := newNTPCollector(runner)
tr := tree.New()
c.Collect(context.Background(), tr)
var out map[string]interface{}
json.Unmarshal(tr.Get("ietf-ntp:ntp"), &out)
cs := out["clock-state"].(map[string]interface{})
ss := cs["system-status"].(map[string]interface{})
// C0A80101 → 192.168.1.1
if ss["clock-refid"] != "192.168.1.1" {
t.Fatalf("clock-refid: expected '192.168.1.1', got %v", ss["clock-refid"])
}
}
func TestSplitLines(t *testing.T) {
input := "line1\n\nline2\n \nline3\n"
got := splitLines(input)
if len(got) != 3 {
t.Fatalf("expected 3 lines, got %d: %v", len(got), got)
}
if got[0] != "line1" || got[1] != "line2" || got[2] != "line3" {
t.Fatalf("unexpected lines: %v", got)
}
}
+748
View File
@@ -0,0 +1,748 @@
package collector
import (
"context"
"encoding/json"
"regexp"
"strconv"
"strings"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
// RoutingCollector gathers ietf-routing operational data by merging
// OSPF, RIP, and BFD control-plane protocols into a single tree key.
// Each protocol contributes entries to the control-plane-protocol list
// under ietf-routing:routing.
type RoutingCollector struct {
cmd CommandRunner
interval time.Duration
}
// NewRoutingCollector creates a RoutingCollector with the given dependencies.
func NewRoutingCollector(cmd CommandRunner, interval time.Duration) *RoutingCollector {
return &RoutingCollector{cmd: cmd, interval: interval}
}
// Name implements Collector.
func (c *RoutingCollector) Name() string { return "routing" }
// Interval implements Collector.
func (c *RoutingCollector) Interval() time.Duration { return c.interval }
// Collect implements Collector. It produces one tree key:
// "ietf-routing:routing" containing merged OSPF, RIP, and BFD data.
func (c *RoutingCollector) Collect(ctx context.Context, t *tree.Tree) error {
var protocols []interface{}
if p := c.collectOSPF(ctx); p != nil {
protocols = append(protocols, p)
}
if p := c.collectRIP(ctx); p != nil {
protocols = append(protocols, p)
}
if p := c.collectBFD(ctx); p != nil {
protocols = append(protocols, p)
}
if len(protocols) == 0 {
return nil
}
routing := map[string]interface{}{
"control-plane-protocols": map[string]interface{}{
"control-plane-protocol": protocols,
},
}
if data, err := json.Marshal(routing); err == nil {
t.Merge("ietf-routing:routing", data)
}
return nil
}
// --- OSPF ---
var ospfIfaceStateMap = map[string]string{
"DependUpon": "down",
"Down": "down",
"Waiting": "waiting",
"Loopback": "loopback",
"Point-To-Point": "point-to-point",
"DROther": "dr-other",
"Backup": "bdr",
"DR": "dr",
}
func frrToIETFNeighborState(state string) string {
parts := strings.SplitN(state, "/", 2)
s := parts[0]
if s == "TwoWay" {
return "2-way"
}
return strings.ToLower(s)
}
func frrToIETFNeighborRole(role string) string {
if role == "Backup" {
return "BDR"
}
return role
}
func ospfNetworkType(nt string, p2mpNonBroadcast bool) string {
switch nt {
case "POINTOPOINT":
return "point-to-point"
case "BROADCAST":
return "broadcast"
case "POINTOMULTIPOINT":
if p2mpNonBroadcast {
return "point-to-multipoint"
}
return "hybrid"
case "NBMA":
return "non-broadcast"
default:
return ""
}
}
func (c *RoutingCollector) collectOSPF(ctx context.Context) interface{} {
out, err := c.cmd.Run(ctx, "/usr/libexec/statd/ospf-status")
if err != nil {
return nil
}
var data map[string]interface{}
if json.Unmarshal(out, &data) != nil || len(data) == 0 {
return nil
}
ospf := map[string]interface{}{
"ietf-ospf:areas": map[string]interface{}{},
}
if rid, ok := data["routerId"]; ok {
ospf["ietf-ospf:router-id"] = rid
}
ospf["ietf-ospf:address-family"] = "ipv4"
areas := make([]interface{}, 0)
areasRaw, _ := data["areas"].(map[string]interface{})
for areaID, valRaw := range areasRaw {
values, ok := valRaw.(map[string]interface{})
if !ok {
continue
}
area := map[string]interface{}{
"ietf-ospf:area-id": areaID,
"ietf-ospf:interfaces": map[string]interface{}{},
}
if at, ok := values["area-type"]; ok && at != nil {
area["ietf-ospf:area-type"] = at
}
interfaces := make([]interface{}, 0)
ifacesRaw, _ := values["interfaces"].([]interface{})
for _, ifaceRaw := range ifacesRaw {
iface, ok := ifaceRaw.(map[string]interface{})
if !ok {
continue
}
intf := map[string]interface{}{
"name": iface["name"],
"ietf-ospf:neighbors": map[string]interface{}{},
}
setIfPresent(intf, "dr-router-id", iface, "drId")
setIfPresent(intf, "dr-ip-addr", iface, "drAddress")
setIfPresent(intf, "bdr-router-id", iface, "bdrId")
setIfPresent(intf, "bdr-ip-addr", iface, "bdrAddress")
if v, ok := iface["timerPassiveIface"]; ok && v != nil {
intf["passive"] = true
} else {
intf["passive"] = false
}
if v, ok := iface["ospfEnabled"]; ok {
intf["enabled"] = v
}
if nt, ok := iface["networkType"].(string); ok {
p2mpNB, _ := iface["p2mpNonBroadcast"].(bool)
if it := ospfNetworkType(nt, p2mpNB); it != "" {
intf["interface-type"] = it
}
}
if s, ok := iface["state"].(string); ok {
if mapped, ok := ospfIfaceStateMap[s]; ok {
intf["state"] = mapped
} else {
intf["state"] = "unknown"
}
}
setIfPresentInt(intf, "priority", iface, "priority")
setIfPresentInt(intf, "cost", iface, "cost")
setIfPresentInt(intf, "dead-interval", iface, "timerDeadSecs")
setIfPresentInt(intf, "retransmit-interval", iface, "timerRetransmitSecs")
setIfPresentInt(intf, "transmit-delay", iface, "transmitDelaySecs")
// Hello interval: milliseconds to seconds
if v := iface["timerMsecs"]; v != nil {
helloSec := toInt(v) / 1000
if helloSec >= 1 {
intf["hello-interval"] = helloSec
}
}
// Hello timer: remaining time in ms to seconds
if v := iface["timerHelloInMsecs"]; v != nil {
helloTimerSec := toInt(v) / 1000
if helloTimerSec >= 1 {
intf["hello-timer"] = helloTimerSec
}
}
// Wait timer
if v := iface["timerWaitSecs"]; v != nil {
waitSec := toInt(v)
if waitSec >= 1 {
intf["wait-timer"] = waitSec
}
}
neighbors := make([]interface{}, 0)
neighsRaw, _ := iface["neighbors"].([]interface{})
for _, neighRaw := range neighsRaw {
neigh, ok := neighRaw.(map[string]interface{})
if !ok {
continue
}
neighbor := map[string]interface{}{
"neighbor-router-id": neigh["neighborIp"],
"address": neigh["ifaceAddress"],
}
setIfPresentInt(neighbor, "priority", neigh, "nbrPriority")
// Uptime: ms to seconds (infix augmentation)
if v := neigh["lastPrgrsvChangeMsec"]; v != nil {
neighbor["infix-routing:uptime"] = toInt(v) / 1000
}
// Dead timer: ms to seconds
if v := neigh["routerDeadIntervalTimerDueMsec"]; v != nil {
deadSec := toInt(v) / 1000
if deadSec >= 1 {
neighbor["dead-timer"] = deadSec
}
}
if s, ok := neigh["nbrState"].(string); ok {
neighbor["state"] = frrToIETFNeighborState(s)
}
if role, ok := neigh["role"].(string); ok && role != "" {
neighbor["infix-routing:role"] = frrToIETFNeighborRole(role)
}
// Interface name (infix augmentation)
ifName, _ := neigh["ifaceName"].(string)
localAddr, _ := neigh["localIfaceAddress"].(string)
if ifName != "" && localAddr != "" {
neighbor["infix-routing:interface-name"] = ifName + ":" + localAddr
} else if ifName != "" {
neighbor["infix-routing:interface-name"] = ifName
}
setIfPresent(neighbor, "dr-router-id", neigh, "routerDesignatedId")
setIfPresent(neighbor, "bdr-router-id", neigh, "routerDesignatedBackupId")
neighbors = append(neighbors, neighbor)
}
intf["ietf-ospf:neighbors"] = map[string]interface{}{
"ietf-ospf:neighbor": neighbors,
}
interfaces = append(interfaces, intf)
}
area["ietf-ospf:interfaces"] = map[string]interface{}{
"ietf-ospf:interface": interfaces,
}
areas = append(areas, area)
}
// Add routes
c.addOSPFRoutes(ctx, ospf)
ospf["ietf-ospf:areas"] = map[string]interface{}{
"ietf-ospf:area": areas,
}
return map[string]interface{}{
"type": "infix-routing:ospfv2",
"name": "default",
"ietf-ospf:ospf": ospf,
}
}
func (c *RoutingCollector) addOSPFRoutes(ctx context.Context, ospf map[string]interface{}) {
out, err := c.cmd.Run(ctx, "vtysh", "-c", "show ip ospf route json")
if err != nil {
return
}
var data map[string]interface{}
if json.Unmarshal(out, &data) != nil {
return
}
var routes []interface{}
for prefix, infoRaw := range data {
if !strings.Contains(prefix, "/") {
continue
}
info, ok := infoRaw.(map[string]interface{})
if !ok {
continue
}
route := map[string]interface{}{
"prefix": prefix,
}
if rt, ok := info["routeType"].(string); ok {
parts := strings.Fields(rt)
if len(parts) > 1 {
switch parts[1] {
case "E1":
route["route-type"] = "external-1"
case "E2":
route["route-type"] = "external-2"
case "IA":
route["route-type"] = "inter-area"
}
} else if len(parts) > 0 && parts[0] == "N" {
route["route-type"] = "intra-area"
}
}
if v := info["area"]; v != nil {
route["infix-routing:area-id"] = v
}
if v := info["cost"]; v != nil {
route["metric"] = v
} else if v := info["metric"]; v != nil {
route["metric"] = v
}
if v := info["tag"]; v != nil {
route["route-tag"] = v
}
nexthops := make([]interface{}, 0)
hopsRaw, _ := info["nexthops"].([]interface{})
for _, hopRaw := range hopsRaw {
hop, ok := hopRaw.(map[string]interface{})
if !ok {
continue
}
nh := make(map[string]interface{})
ip, _ := hop["ip"].(string)
if ip != "" && ip != " " {
nh["next-hop"] = ip
} else if da, ok := hop["directlyAttachedTo"].(string); ok {
nh["outgoing-interface"] = da
}
nexthops = append(nexthops, nh)
}
route["next-hops"] = map[string]interface{}{
"next-hop": nexthops,
}
routes = append(routes, route)
}
if len(routes) > 0 {
ospf["ietf-ospf:local-rib"] = map[string]interface{}{
"ietf-ospf:route": routes,
}
}
}
// --- RIP ---
var ripStatusUpdateRe = regexp.MustCompile(`Sending updates every (\d+) seconds`)
var ripStatusTimeoutRe = regexp.MustCompile(`Timeout after (\d+) seconds`)
var ripStatusFlushRe = regexp.MustCompile(`garbage collect after (\d+) seconds`)
var ripStatusMetricRe = regexp.MustCompile(`Default redistribution metric is (\d+)`)
var ripStatusDistanceRe = regexp.MustCompile(`Distance: \(default is (\d+)\)`)
func (c *RoutingCollector) collectRIP(ctx context.Context) interface{} {
statusOut, err := c.cmd.Run(ctx, "vtysh", "-c", "show ip rip status")
if err != nil {
return nil
}
statusText := string(statusOut)
if statusText == "" {
return nil
}
status := parseRIPStatus(statusText)
if len(status) == 0 {
return nil
}
rip := make(map[string]interface{})
if v, ok := status["distance"]; ok {
rip["distance"] = v
}
if v, ok := status["default-metric"]; ok {
rip["default-metric"] = v
}
timers := make(map[string]interface{})
if v, ok := status["update-interval"]; ok {
timers["update-interval"] = v
}
if v, ok := status["invalid-interval"]; ok {
timers["invalid-interval"] = v
}
if v, ok := status["flush-interval"]; ok {
timers["flush-interval"] = v
}
if len(timers) > 0 {
rip["timers"] = timers
}
if ifaces, ok := status["interfaces"].([]interface{}); ok && len(ifaces) > 0 {
var ifaceList []interface{}
for _, ifRaw := range ifaces {
ifData, ok := ifRaw.(map[string]interface{})
if !ok {
continue
}
entry := map[string]interface{}{
"interface": ifData["name"],
"oper-status": "up",
}
if sv, ok := ifData["send-version"].(int); ok {
entry["send-version"] = strconv.Itoa(sv)
}
if rv, ok := ifData["recv-version"].(int); ok {
entry["receive-version"] = strconv.Itoa(rv)
}
ifaceList = append(ifaceList, entry)
}
if len(ifaceList) > 0 {
rip["interfaces"] = map[string]interface{}{
"interface": ifaceList,
}
}
}
routeOut, err := c.cmd.Run(ctx, "vtysh", "-c", "show ip route rip json")
if err == nil {
var routeData map[string]interface{}
if json.Unmarshal(routeOut, &routeData) == nil {
var routes []interface{}
for prefix, entriesRaw := range routeData {
if !strings.Contains(prefix, "/") {
continue
}
entries, ok := entriesRaw.([]interface{})
if !ok || len(entries) == 0 {
continue
}
entry, ok := entries[0].(map[string]interface{})
if !ok {
continue
}
route := map[string]interface{}{
"ipv4-prefix": prefix,
"route-type": "rip",
}
if m, ok := entry["metric"]; ok {
route["metric"] = toInt(m)
}
nexthops, _ := entry["nexthops"].([]interface{})
if len(nexthops) > 0 {
firstHop, _ := nexthops[0].(map[string]interface{})
if ip, ok := firstHop["ip"].(string); ok && ip != "" {
route["next-hop"] = ip
}
if ifName, ok := firstHop["interfaceName"].(string); ok && ifName != "" {
route["interface"] = ifName
}
}
routes = append(routes, route)
}
if len(routes) > 0 {
if _, ok := rip["ipv4"]; !ok {
rip["ipv4"] = make(map[string]interface{})
}
rip["ipv4"].(map[string]interface{})["routes"] = map[string]interface{}{
"route": routes,
}
rip["num-of-routes"] = len(routes)
}
}
}
if neighs, ok := status["neighbors"].([]interface{}); ok && len(neighs) > 0 {
var neighborList []interface{}
for _, nRaw := range neighs {
nd, ok := nRaw.(map[string]interface{})
if !ok {
continue
}
entry := map[string]interface{}{
"ipv4-address": nd["address"],
}
if v, ok := nd["bad-packets"].(int); ok {
entry["bad-packets-rcvd"] = v
}
if v, ok := nd["bad-routes"].(int); ok {
entry["bad-routes-rcvd"] = v
}
neighborList = append(neighborList, entry)
}
if len(neighborList) > 0 {
if _, ok := rip["ipv4"]; !ok {
rip["ipv4"] = make(map[string]interface{})
}
rip["ipv4"].(map[string]interface{})["neighbors"] = map[string]interface{}{
"neighbor": neighborList,
}
}
}
return map[string]interface{}{
"type": "infix-routing:ripv2",
"name": "default",
"ietf-rip:rip": rip,
}
}
// parseRIPStatus parses the text output of 'show ip rip status'.
func parseRIPStatus(text string) map[string]interface{} {
status := make(map[string]interface{})
if m := ripStatusUpdateRe.FindStringSubmatch(text); m != nil {
v, _ := strconv.Atoi(m[1])
status["update-interval"] = v
}
if m := ripStatusTimeoutRe.FindStringSubmatch(text); m != nil {
v, _ := strconv.Atoi(m[1])
status["invalid-interval"] = v
}
if m := ripStatusFlushRe.FindStringSubmatch(text); m != nil {
v, _ := strconv.Atoi(m[1])
status["flush-interval"] = v
}
if m := ripStatusMetricRe.FindStringSubmatch(text); m != nil {
v, _ := strconv.Atoi(m[1])
status["default-metric"] = v
}
if m := ripStatusDistanceRe.FindStringSubmatch(text); m != nil {
v, _ := strconv.Atoi(m[1])
status["distance"] = v
}
// Parse interface table
lines := strings.Split(text, "\n")
var interfaces []interface{}
inIfaceSection := false
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.Contains(line, "Interface") && strings.Contains(line, "Send") && strings.Contains(line, "Recv") {
inIfaceSection = true
continue
}
if inIfaceSection && (strings.HasPrefix(line, "Routing for Networks:") || strings.HasPrefix(line, "Routing Information Sources:")) {
break
}
if inIfaceSection && line != "" {
parts := strings.Fields(line)
if len(parts) >= 3 && !strings.HasPrefix(line, "Interface") {
sendVer, err1 := strconv.Atoi(parts[1])
recvVer, err2 := strconv.Atoi(parts[2])
if err1 == nil && err2 == nil {
interfaces = append(interfaces, map[string]interface{}{
"name": parts[0],
"send-version": sendVer,
"recv-version": recvVer,
})
}
}
}
}
if len(interfaces) > 0 {
status["interfaces"] = interfaces
}
// Parse Routing Information Sources table (neighbors)
var neighbors []interface{}
inNeighborSection := false
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Routing Information Sources:") {
inNeighborSection = true
continue
}
if inNeighborSection && strings.Contains(line, "Gateway") && strings.Contains(line, "BadPackets") {
continue
}
if inNeighborSection && (strings.HasPrefix(line, "Distance:") || (line == "" && len(neighbors) > 0)) {
break
}
if inNeighborSection && line != "" {
parts := strings.Fields(line)
if len(parts) >= 5 {
badPkts, err1 := strconv.Atoi(parts[1])
badRoutes, err2 := strconv.Atoi(parts[2])
if err1 == nil && err2 == nil {
neighbors = append(neighbors, map[string]interface{}{
"address": parts[0],
"bad-packets": badPkts,
"bad-routes": badRoutes,
})
}
}
}
}
if len(neighbors) > 0 {
status["neighbors"] = neighbors
}
return status
}
// --- BFD ---
var bfdStateMap = map[string]string{
"up": "up",
"down": "down",
"init": "init",
"adminDown": "adminDown",
}
func (c *RoutingCollector) collectBFD(ctx context.Context) interface{} {
out, err := c.cmd.Run(ctx, "vtysh", "-c", "show bfd peers json")
if err != nil {
return nil
}
var data []interface{}
if json.Unmarshal(out, &data) != nil || len(data) == 0 {
return nil
}
var sessions []interface{}
for _, peerRaw := range data {
peer, ok := peerRaw.(map[string]interface{})
if !ok {
continue
}
// Only process single-hop sessions (multihop == false)
if mh, _ := peer["multihop"].(bool); mh {
continue
}
session := map[string]interface{}{
"interface": strDefault(peer["interface"], "unknown"),
"dest-addr": strDefault(peer["peer"], "0.0.0.0"),
}
if v := peer["id"]; v != nil {
session["local-discriminator"] = v
}
if v := peer["remote-id"]; v != nil {
session["remote-discriminator"] = v
}
state := strDefault(peer["status"], "down")
ietfState := bfdStateMap[state]
if ietfState == "" {
ietfState = "down"
}
sessionRunning := map[string]interface{}{
"local-state": ietfState,
"remote-state": ietfState,
"local-diagnostic": "none",
"detection-mode": "async-without-echo",
}
if v := peer["receive-interval"]; v != nil {
sessionRunning["negotiated-rx-interval"] = toInt(v) * 1000
}
if v := peer["transmit-interval"]; v != nil {
sessionRunning["negotiated-tx-interval"] = toInt(v) * 1000
}
if dm := peer["detect-multiplier"]; dm != nil {
if ri := peer["receive-interval"]; ri != nil {
detectionTimeMs := toInt(dm) * toInt(ri)
sessionRunning["detection-time"] = detectionTimeMs * 1000
}
}
session["session-running"] = sessionRunning
session["path-type"] = "ietf-bfd-types:path-ip-sh"
session["ip-encapsulation"] = true
sessions = append(sessions, session)
}
if len(sessions) == 0 {
return nil
}
return map[string]interface{}{
"type": "infix-routing:bfdv1",
"name": "bfd",
"ietf-bfd:bfd": map[string]interface{}{
"ietf-bfd-ip-sh:ip-sh": map[string]interface{}{
"sessions": map[string]interface{}{
"session": sessions,
},
},
},
}
}
// --- Helpers ---
func setIfPresent(dst map[string]interface{}, dstKey string, src map[string]interface{}, srcKey string) {
if v, ok := src[srcKey]; ok && v != nil {
dst[dstKey] = v
}
}
func setIfPresentInt(dst map[string]interface{}, dstKey string, src map[string]interface{}, srcKey string) {
if v, ok := src[srcKey]; ok && v != nil {
dst[dstKey] = toInt(v)
}
}
func strDefault(v interface{}, def string) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return def
}
@@ -0,0 +1,721 @@
package collector
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/testutil"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
// Canned FRR JSON matching real /usr/libexec/statd/ospf-status output.
const testOSPFStatus = `{
"routerId": "10.0.0.1",
"areas": {
"0.0.0.0": {
"area-type": "ietf-ospf:normal-area",
"interfaces": [
{
"name": "e0",
"state": "DR",
"ospfEnabled": true,
"networkType": "BROADCAST",
"cost": 10,
"priority": 1,
"timerDeadSecs": 40,
"timerRetransmitSecs": 5,
"transmitDelaySecs": 1,
"timerMsecs": 10000,
"timerHelloInMsecs": 7000,
"timerWaitSecs": 40,
"drId": "10.0.0.1",
"drAddress": "192.168.1.1",
"bdrId": "10.0.0.2",
"bdrAddress": "192.168.1.2",
"neighbors": [
{
"neighborIp": "10.0.0.2",
"ifaceAddress": "192.168.1.2",
"nbrPriority": 1,
"nbrState": "Full/DR",
"role": "Backup",
"lastPrgrsvChangeMsec": 120000,
"routerDeadIntervalTimerDueMsec": 35000,
"routerDesignatedId": "10.0.0.1",
"routerDesignatedBackupId": "10.0.0.2",
"ifaceName": "e0",
"localIfaceAddress": "192.168.1.1"
}
]
},
{
"name": "lo",
"state": "Loopback",
"ospfEnabled": true,
"networkType": "POINTOPOINT",
"cost": 0,
"priority": 0,
"timerPassiveIface": true,
"timerDeadSecs": 0,
"timerRetransmitSecs": 0,
"transmitDelaySecs": 0,
"timerMsecs": 10000,
"neighbors": []
}
]
}
}
}`
const testOSPFRoutes = `{
"10.0.0.0/24": {
"routeType": "N IA",
"area": "0.0.0.0",
"cost": 20,
"nexthops": [
{"ip": "192.168.1.2", "via": "e0"}
]
},
"10.0.1.0/24": {
"routeType": "N E2",
"area": "0.0.0.0",
"cost": 100,
"tag": 42,
"nexthops": [
{"ip": " ", "directlyAttachedTo": "e0"}
]
}
}`
const testRIPStatus = `Routing Protocol is "rip"
Sending updates every 30 seconds with +/-50%, next due in 12 seconds
Timeout after 180 seconds, garbage collect after 120 seconds
Outgoing update filter list for all interface is not set
Incoming update filter list for all interface is not set
Default redistribution metric is 1
Redistributing:
Default version control: send version 2, receive version 2
Interface Send Recv Key-chain
e0 2 2
e1 2 2
Routing for Networks:
10.0.0.0/24
10.0.1.0/24
Routing Information Sources:
Gateway BadPackets BadRoutes Distance Last Update
10.0.0.2 0 0 120 00:00:12
10.0.0.3 1 2 120 00:00:25
Distance: (default is 120)
`
const testRIPRoutes = `{
"10.0.0.0/24": [
{
"prefix": "10.0.0.0/24",
"protocol": "rip",
"metric": 1,
"nexthops": [
{"ip": "10.0.0.2", "interfaceName": "e0"}
]
}
],
"10.0.1.0/24": [
{
"prefix": "10.0.1.0/24",
"protocol": "rip",
"metric": 2,
"nexthops": [
{"ip": "10.0.0.3", "interfaceName": "e1"}
]
}
]
}`
const testBFDPeers = `[
{
"multihop": false,
"peer": "10.0.0.2",
"interface": "e0",
"id": 1,
"remote-id": 2,
"status": "up",
"receive-interval": 300,
"transmit-interval": 300,
"detect-multiplier": 3
},
{
"multihop": true,
"peer": "10.0.0.99",
"interface": "e1",
"id": 5,
"remote-id": 6,
"status": "down"
}
]`
func newRoutingCollector(runner *testutil.MockRunner) *RoutingCollector {
return NewRoutingCollector(runner, 10*time.Second)
}
func routingCollect(t *testing.T, runner *testutil.MockRunner) map[string]interface{} {
t.Helper()
c := newRoutingCollector(runner)
tr := tree.New()
if err := c.Collect(context.Background(), tr); err != nil {
t.Fatalf("Collect failed: %v", err)
}
raw := tr.Get("ietf-routing:routing")
if raw == nil {
t.Fatal("missing ietf-routing:routing in tree")
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal routing: %v", err)
}
return out
}
func fullRunner() *testutil.MockRunner {
return &testutil.MockRunner{
Results: map[string][]byte{
"/usr/libexec/statd/ospf-status": []byte(testOSPFStatus),
"vtysh -c show ip ospf route json": []byte(testOSPFRoutes),
"vtysh -c show ip rip status": []byte(testRIPStatus),
"vtysh -c show ip route rip json": []byte(testRIPRoutes),
"vtysh -c show bfd peers json": []byte(testBFDPeers),
},
Errors: map[string]error{},
}
}
func TestRoutingCollectorNameAndInterval(t *testing.T) {
c := newRoutingCollector(fullRunner())
if c.Name() != "routing" {
t.Fatalf("expected name 'routing', got %q", c.Name())
}
if c.Interval() != 10*time.Second {
t.Fatalf("expected interval 10s, got %v", c.Interval())
}
}
func TestRoutingCollectorMergesThreeProtocols(t *testing.T) {
out := routingCollect(t, fullRunner())
cpp := out["control-plane-protocols"].(map[string]interface{})
protocols := cpp["control-plane-protocol"].([]interface{})
if len(protocols) != 3 {
t.Fatalf("expected 3 protocols (OSPF+RIP+BFD), got %d", len(protocols))
}
types := make(map[string]bool)
for _, p := range protocols {
pm := p.(map[string]interface{})
types[pm["type"].(string)] = true
}
for _, expected := range []string{"infix-routing:ospfv2", "infix-routing:ripv2", "infix-routing:bfdv1"} {
if !types[expected] {
t.Fatalf("missing protocol type %q; got %v", expected, types)
}
}
}
// --- OSPF tests ---
func getOSPFProtocol(t *testing.T, out map[string]interface{}) map[string]interface{} {
t.Helper()
cpp := out["control-plane-protocols"].(map[string]interface{})
for _, p := range cpp["control-plane-protocol"].([]interface{}) {
pm := p.(map[string]interface{})
if pm["type"] == "infix-routing:ospfv2" {
return pm
}
}
t.Fatal("OSPF protocol not found")
return nil
}
func TestOSPFRouterID(t *testing.T) {
out := routingCollect(t, fullRunner())
ospfProto := getOSPFProtocol(t, out)
ospf := ospfProto["ietf-ospf:ospf"].(map[string]interface{})
if ospf["ietf-ospf:router-id"] != "10.0.0.1" {
t.Fatalf("router-id: expected 10.0.0.1, got %v", ospf["ietf-ospf:router-id"])
}
if ospf["ietf-ospf:address-family"] != "ipv4" {
t.Fatalf("address-family: expected ipv4, got %v", ospf["ietf-ospf:address-family"])
}
}
func TestOSPFAreaAndInterfaces(t *testing.T) {
out := routingCollect(t, fullRunner())
ospfProto := getOSPFProtocol(t, out)
ospf := ospfProto["ietf-ospf:ospf"].(map[string]interface{})
areasContainer := ospf["ietf-ospf:areas"].(map[string]interface{})
areas := areasContainer["ietf-ospf:area"].([]interface{})
if len(areas) != 1 {
t.Fatalf("expected 1 area, got %d", len(areas))
}
area := areas[0].(map[string]interface{})
if area["ietf-ospf:area-id"] != "0.0.0.0" {
t.Fatalf("area-id: expected 0.0.0.0, got %v", area["ietf-ospf:area-id"])
}
ifacesContainer := area["ietf-ospf:interfaces"].(map[string]interface{})
ifaces := ifacesContainer["ietf-ospf:interface"].([]interface{})
if len(ifaces) != 2 {
t.Fatalf("expected 2 interfaces, got %d", len(ifaces))
}
// First interface: e0 (DR)
e0 := ifaces[0].(map[string]interface{})
if e0["name"] != "e0" {
t.Fatalf("interface[0] name: expected e0, got %v", e0["name"])
}
if e0["state"] != "dr" {
t.Fatalf("interface[0] state: expected dr, got %v", e0["state"])
}
if e0["interface-type"] != "broadcast" {
t.Fatalf("interface[0] type: expected broadcast, got %v", e0["interface-type"])
}
if e0["passive"] != false {
t.Fatalf("e0 passive: expected false, got %v", e0["passive"])
}
if e0["enabled"] != true {
t.Fatalf("e0 enabled: expected true, got %v", e0["enabled"])
}
if e0["dr-router-id"] != "10.0.0.1" {
t.Fatalf("e0 dr-router-id: expected 10.0.0.1, got %v", e0["dr-router-id"])
}
// Check timer conversions (ms → seconds)
if toInt(e0["hello-interval"]) != 10 {
t.Fatalf("e0 hello-interval: expected 10, got %v", e0["hello-interval"])
}
if toInt(e0["hello-timer"]) != 7 {
t.Fatalf("e0 hello-timer: expected 7, got %v", e0["hello-timer"])
}
if toInt(e0["cost"]) != 10 {
t.Fatalf("e0 cost: expected 10, got %v", e0["cost"])
}
// Second interface: lo (passive loopback)
lo := ifaces[1].(map[string]interface{})
if lo["name"] != "lo" {
t.Fatalf("interface[1] name: expected lo, got %v", lo["name"])
}
if lo["state"] != "loopback" {
t.Fatalf("lo state: expected loopback, got %v", lo["state"])
}
if lo["passive"] != true {
t.Fatalf("lo passive: expected true, got %v", lo["passive"])
}
if lo["interface-type"] != "point-to-point" {
t.Fatalf("lo type: expected point-to-point, got %v", lo["interface-type"])
}
}
func TestOSPFNeighbors(t *testing.T) {
out := routingCollect(t, fullRunner())
ospfProto := getOSPFProtocol(t, out)
ospf := ospfProto["ietf-ospf:ospf"].(map[string]interface{})
areasContainer := ospf["ietf-ospf:areas"].(map[string]interface{})
area := areasContainer["ietf-ospf:area"].([]interface{})[0].(map[string]interface{})
ifacesContainer := area["ietf-ospf:interfaces"].(map[string]interface{})
e0 := ifacesContainer["ietf-ospf:interface"].([]interface{})[0].(map[string]interface{})
neighborsContainer := e0["ietf-ospf:neighbors"].(map[string]interface{})
neighbors := neighborsContainer["ietf-ospf:neighbor"].([]interface{})
if len(neighbors) != 1 {
t.Fatalf("expected 1 neighbor, got %d", len(neighbors))
}
n := neighbors[0].(map[string]interface{})
if n["neighbor-router-id"] != "10.0.0.2" {
t.Fatalf("neighbor router-id: expected 10.0.0.2, got %v", n["neighbor-router-id"])
}
if n["address"] != "192.168.1.2" {
t.Fatalf("neighbor address: expected 192.168.1.2, got %v", n["address"])
}
if n["state"] != "full" {
t.Fatalf("neighbor state: expected full, got %v", n["state"])
}
if n["infix-routing:role"] != "BDR" {
t.Fatalf("neighbor role: expected BDR, got %v", n["infix-routing:role"])
}
// Uptime: 120000ms → 120s
if toInt(n["infix-routing:uptime"]) != 120 {
t.Fatalf("neighbor uptime: expected 120, got %v", n["infix-routing:uptime"])
}
// Dead timer: 35000ms → 35s
if toInt(n["dead-timer"]) != 35 {
t.Fatalf("neighbor dead-timer: expected 35, got %v", n["dead-timer"])
}
// Interface name augmentation
if n["infix-routing:interface-name"] != "e0:192.168.1.1" {
t.Fatalf("neighbor interface-name: expected e0:192.168.1.1, got %v", n["infix-routing:interface-name"])
}
}
func TestOSPFRoutes(t *testing.T) {
out := routingCollect(t, fullRunner())
ospfProto := getOSPFProtocol(t, out)
ospf := ospfProto["ietf-ospf:ospf"].(map[string]interface{})
rib := ospf["ietf-ospf:local-rib"].(map[string]interface{})
routes := rib["ietf-ospf:route"].([]interface{})
if len(routes) != 2 {
t.Fatalf("expected 2 OSPF routes, got %d", len(routes))
}
routeByPrefix := make(map[string]map[string]interface{})
for _, r := range routes {
rm := r.(map[string]interface{})
routeByPrefix[rm["prefix"].(string)] = rm
}
// Inter-area route
r1 := routeByPrefix["10.0.0.0/24"]
if r1 == nil {
t.Fatal("missing route 10.0.0.0/24")
}
if r1["route-type"] != "inter-area" {
t.Fatalf("route 10.0.0.0/24 type: expected inter-area, got %v", r1["route-type"])
}
// External-2 route with tag
r2 := routeByPrefix["10.0.1.0/24"]
if r2 == nil {
t.Fatal("missing route 10.0.1.0/24")
}
if r2["route-type"] != "external-2" {
t.Fatalf("route 10.0.1.0/24 type: expected external-2, got %v", r2["route-type"])
}
// tag should be present
if r2["route-tag"] == nil {
t.Fatal("route 10.0.1.0/24 should have route-tag")
}
// Directly attached nexthop
nhs := r2["next-hops"].(map[string]interface{})
nhList := nhs["next-hop"].([]interface{})
if len(nhList) != 1 {
t.Fatalf("expected 1 nexthop, got %d", len(nhList))
}
nh := nhList[0].(map[string]interface{})
if nh["outgoing-interface"] != "e0" {
t.Fatalf("nexthop outgoing-interface: expected e0, got %v", nh["outgoing-interface"])
}
}
// --- RIP tests ---
func getRIPProtocol(t *testing.T, out map[string]interface{}) map[string]interface{} {
t.Helper()
cpp := out["control-plane-protocols"].(map[string]interface{})
for _, p := range cpp["control-plane-protocol"].([]interface{}) {
pm := p.(map[string]interface{})
if pm["type"] == "infix-routing:ripv2" {
return pm
}
}
t.Fatal("RIP protocol not found")
return nil
}
func TestRIPTimers(t *testing.T) {
out := routingCollect(t, fullRunner())
ripProto := getRIPProtocol(t, out)
rip := ripProto["ietf-rip:rip"].(map[string]interface{})
timers := rip["timers"].(map[string]interface{})
if toInt(timers["update-interval"]) != 30 {
t.Fatalf("RIP update-interval: expected 30, got %v", timers["update-interval"])
}
if toInt(timers["invalid-interval"]) != 180 {
t.Fatalf("RIP invalid-interval: expected 180, got %v", timers["invalid-interval"])
}
if toInt(timers["flush-interval"]) != 120 {
t.Fatalf("RIP flush-interval: expected 120, got %v", timers["flush-interval"])
}
if toInt(rip["default-metric"]) != 1 {
t.Fatalf("RIP default-metric: expected 1, got %v", rip["default-metric"])
}
if toInt(rip["distance"]) != 120 {
t.Fatalf("RIP distance: expected 120, got %v", rip["distance"])
}
}
func TestRIPInterfaces(t *testing.T) {
out := routingCollect(t, fullRunner())
ripProto := getRIPProtocol(t, out)
rip := ripProto["ietf-rip:rip"].(map[string]interface{})
ifContainer := rip["interfaces"].(map[string]interface{})
ifaces := ifContainer["interface"].([]interface{})
if len(ifaces) != 2 {
t.Fatalf("expected 2 RIP interfaces, got %d", len(ifaces))
}
iface0 := ifaces[0].(map[string]interface{})
if iface0["interface"] != "e0" {
t.Fatalf("RIP iface[0]: expected e0, got %v", iface0["interface"])
}
if iface0["oper-status"] != "up" {
t.Fatalf("RIP iface[0] status: expected up, got %v", iface0["oper-status"])
}
if iface0["send-version"] != "2" {
t.Fatalf("RIP iface[0] send-version: expected '2', got %v", iface0["send-version"])
}
}
func TestRIPRoutes(t *testing.T) {
out := routingCollect(t, fullRunner())
ripProto := getRIPProtocol(t, out)
rip := ripProto["ietf-rip:rip"].(map[string]interface{})
ipv4 := rip["ipv4"].(map[string]interface{})
routesContainer := ipv4["routes"].(map[string]interface{})
routes := routesContainer["route"].([]interface{})
if len(routes) != 2 {
t.Fatalf("expected 2 RIP routes, got %d", len(routes))
}
if toInt(rip["num-of-routes"]) != 2 {
t.Fatalf("RIP num-of-routes: expected 2, got %v", rip["num-of-routes"])
}
}
func TestRIPNeighbors(t *testing.T) {
out := routingCollect(t, fullRunner())
ripProto := getRIPProtocol(t, out)
rip := ripProto["ietf-rip:rip"].(map[string]interface{})
ipv4 := rip["ipv4"].(map[string]interface{})
neighContainer := ipv4["neighbors"].(map[string]interface{})
neighs := neighContainer["neighbor"].([]interface{})
if len(neighs) != 2 {
t.Fatalf("expected 2 RIP neighbors, got %d", len(neighs))
}
n0 := neighs[0].(map[string]interface{})
if n0["ipv4-address"] != "10.0.0.2" {
t.Fatalf("RIP neighbor[0] address: expected 10.0.0.2, got %v", n0["ipv4-address"])
}
// Bad packets/routes should be int (from text parse)
if toInt(n0["bad-packets-rcvd"]) != 0 {
t.Fatalf("RIP neighbor[0] bad-packets: expected 0, got %v", n0["bad-packets-rcvd"])
}
n1 := neighs[1].(map[string]interface{})
if toInt(n1["bad-packets-rcvd"]) != 1 {
t.Fatalf("RIP neighbor[1] bad-packets: expected 1, got %v", n1["bad-packets-rcvd"])
}
if toInt(n1["bad-routes-rcvd"]) != 2 {
t.Fatalf("RIP neighbor[1] bad-routes: expected 2, got %v", n1["bad-routes-rcvd"])
}
}
// --- BFD tests ---
func getBFDProtocol(t *testing.T, out map[string]interface{}) map[string]interface{} {
t.Helper()
cpp := out["control-plane-protocols"].(map[string]interface{})
for _, p := range cpp["control-plane-protocol"].([]interface{}) {
pm := p.(map[string]interface{})
if pm["type"] == "infix-routing:bfdv1" {
return pm
}
}
t.Fatal("BFD protocol not found")
return nil
}
func TestBFDSessions(t *testing.T) {
out := routingCollect(t, fullRunner())
bfdProto := getBFDProtocol(t, out)
bfd := bfdProto["ietf-bfd:bfd"].(map[string]interface{})
ipsh := bfd["ietf-bfd-ip-sh:ip-sh"].(map[string]interface{})
sessionsContainer := ipsh["sessions"].(map[string]interface{})
sessions := sessionsContainer["session"].([]interface{})
// Only single-hop sessions included (multihop=true is filtered)
if len(sessions) != 1 {
t.Fatalf("expected 1 BFD session (multihop filtered), got %d", len(sessions))
}
s := sessions[0].(map[string]interface{})
if s["interface"] != "e0" {
t.Fatalf("BFD session interface: expected e0, got %v", s["interface"])
}
if s["dest-addr"] != "10.0.0.2" {
t.Fatalf("BFD session dest-addr: expected 10.0.0.2, got %v", s["dest-addr"])
}
if s["path-type"] != "ietf-bfd-types:path-ip-sh" {
t.Fatalf("BFD path-type: expected ietf-bfd-types:path-ip-sh, got %v", s["path-type"])
}
running := s["session-running"].(map[string]interface{})
if running["local-state"] != "up" {
t.Fatalf("BFD local-state: expected up, got %v", running["local-state"])
}
if running["detection-mode"] != "async-without-echo" {
t.Fatalf("BFD detection-mode: expected async-without-echo, got %v", running["detection-mode"])
}
// Intervals: ms → µs (×1000)
// receive-interval=300ms → 300000µs
if toInt(running["negotiated-rx-interval"]) != 300000 {
t.Fatalf("BFD rx-interval: expected 300000, got %v", running["negotiated-rx-interval"])
}
if toInt(running["negotiated-tx-interval"]) != 300000 {
t.Fatalf("BFD tx-interval: expected 300000, got %v", running["negotiated-tx-interval"])
}
// detection-time = detect-multiplier * receive-interval * 1000 = 3 * 300 * 1000 = 900000
if toInt(running["detection-time"]) != 900000 {
t.Fatalf("BFD detection-time: expected 900000, got %v", running["detection-time"])
}
}
// --- Graceful degradation tests ---
func TestRoutingCollectorOSPFOnly(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"/usr/libexec/statd/ospf-status": []byte(testOSPFStatus),
"vtysh -c show ip ospf route json": []byte(testOSPFRoutes),
},
Errors: map[string]error{},
}
out := routingCollect(t, runner)
cpp := out["control-plane-protocols"].(map[string]interface{})
protocols := cpp["control-plane-protocol"].([]interface{})
if len(protocols) != 1 {
t.Fatalf("expected 1 protocol when only OSPF available, got %d", len(protocols))
}
pm := protocols[0].(map[string]interface{})
if pm["type"] != "infix-routing:ospfv2" {
t.Fatalf("expected OSPF protocol, got %v", pm["type"])
}
}
func TestRoutingCollectorAllFail(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{},
Errors: map[string]error{},
}
c := newRoutingCollector(runner)
tr := tree.New()
err := c.Collect(context.Background(), tr)
if err != nil {
t.Fatalf("Collect should not error when all protocols fail: %v", err)
}
// No tree key should be set when there's nothing to report
if tr.Get("ietf-routing:routing") != nil {
t.Fatal("expected no ietf-routing:routing key when all protocols fail")
}
}
func TestRIPStatusParsing(t *testing.T) {
status := parseRIPStatus(testRIPStatus)
if status["update-interval"] != 30 {
t.Fatalf("update-interval: expected 30, got %v", status["update-interval"])
}
if status["invalid-interval"] != 180 {
t.Fatalf("invalid-interval: expected 180, got %v", status["invalid-interval"])
}
if status["flush-interval"] != 120 {
t.Fatalf("flush-interval: expected 120, got %v", status["flush-interval"])
}
if status["default-metric"] != 1 {
t.Fatalf("default-metric: expected 1, got %v", status["default-metric"])
}
if status["distance"] != 120 {
t.Fatalf("distance: expected 120, got %v", status["distance"])
}
ifaces := status["interfaces"].([]interface{})
if len(ifaces) != 2 {
t.Fatalf("expected 2 parsed interfaces, got %d", len(ifaces))
}
neighs := status["neighbors"].([]interface{})
if len(neighs) != 2 {
t.Fatalf("expected 2 parsed neighbors, got %d", len(neighs))
}
}
func TestFrrToIETFNeighborState(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"Full/DR", "full"},
{"TwoWay/DROther", "2-way"},
{"Init/DROther", "init"},
{"Down/DROther", "down"},
{"ExStart", "exstart"},
}
for _, tt := range tests {
got := frrToIETFNeighborState(tt.input)
if got != tt.expected {
t.Fatalf("frrToIETFNeighborState(%q): expected %q, got %q", tt.input, tt.expected, got)
}
}
}
func TestOSPFNetworkType(t *testing.T) {
tests := []struct {
nt string
p2mpNB bool
expected string
}{
{"POINTOPOINT", false, "point-to-point"},
{"BROADCAST", false, "broadcast"},
{"POINTOMULTIPOINT", false, "hybrid"},
{"POINTOMULTIPOINT", true, "point-to-multipoint"},
{"NBMA", false, "non-broadcast"},
{"UNKNOWN", false, ""},
}
for _, tt := range tests {
got := ospfNetworkType(tt.nt, tt.p2mpNB)
if got != tt.expected {
t.Fatalf("ospfNetworkType(%q, %v): expected %q, got %q", tt.nt, tt.p2mpNB, tt.expected, got)
}
}
}
func TestBFDMultihopFiltered(t *testing.T) {
// Ensure multihop peers don't appear in output
runner := &testutil.MockRunner{
Results: map[string][]byte{
"vtysh -c show bfd peers json": []byte(`[
{"multihop": true, "peer": "10.0.0.99", "interface": "e1", "id": 5, "status": "up"}
]`),
},
Errors: map[string]error{},
}
c := newRoutingCollector(runner)
tr := tree.New()
c.Collect(context.Background(), tr)
// BFD should not set anything when all peers are multihop
raw := tr.Get("ietf-routing:routing")
if raw != nil {
// If routing is set, BFD should not be present
var out map[string]interface{}
json.Unmarshal(raw, &out)
cpp := out["control-plane-protocols"].(map[string]interface{})
protocols := cpp["control-plane-protocol"].([]interface{})
for _, p := range protocols {
pm := p.(map[string]interface{})
if pm["type"] == "infix-routing:bfdv1" {
t.Fatal("multihop-only BFD should not produce a protocol entry")
}
}
}
}
+83
View File
@@ -0,0 +1,83 @@
package collector
import (
"context"
"os"
"os/exec"
"path/filepath"
"github.com/godbus/dbus/v5"
)
// CommandRunner executes external commands and returns their stdout.
type CommandRunner interface {
Run(ctx context.Context, name string, args ...string) ([]byte, error)
}
// FileReader reads files and globs paths on the filesystem.
type FileReader interface {
ReadFile(path string) ([]byte, error)
Glob(pattern string) ([]string, error)
}
// InstallerStatus queries RAUC installation progress.
type InstallerStatus interface {
GetInstallStatus() (operation string, lastError string, percentage int, message string, err error)
}
// ExecRunner is the production CommandRunner using os/exec.
type ExecRunner struct{}
func (ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).Output()
}
// OSFileReader is the production FileReader using the os package.
type OSFileReader struct{}
func (OSFileReader) ReadFile(path string) ([]byte, error) {
return os.ReadFile(path)
}
func (OSFileReader) Glob(pattern string) ([]string, error) {
return filepath.Glob(pattern)
}
// DBusInstaller reads RAUC installation status from D-Bus properties.
type DBusInstaller struct{}
func (DBusInstaller) GetInstallStatus() (string, string, int, string, error) {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return "", "", 0, "", err
}
defer conn.Close()
obj := conn.Object("de.pengutronix.rauc", "/")
operation, _ := obj.GetProperty("de.pengutronix.rauc.Installer.Operation")
lastError, _ := obj.GetProperty("de.pengutronix.rauc.Installer.LastError")
var pct int
var msg string
progress, err := obj.GetProperty("de.pengutronix.rauc.Installer.Progress")
if err == nil {
if vals, ok := progress.Value().([]interface{}); ok && len(vals) >= 2 {
if p, ok := vals[0].(int32); ok {
pct = int(p)
}
if s, ok := vals[1].(string); ok {
msg = s
}
}
}
return variantString(operation), variantString(lastError), pct, msg, nil
}
func variantString(v dbus.Variant) string {
if s, ok := v.Value().(string); ok {
return s
}
return ""
}
+120
View File
@@ -0,0 +1,120 @@
package collector
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
var platformKeyMap = map[string]string{
"NAME": "os-name",
"VERSION_ID": "os-version",
"BUILD_ID": "os-release",
"ARCHITECTURE": "machine",
}
// SystemCollector gathers ietf-system operational data.
type SystemCollector struct {
cmd CommandRunner
fs FileReader
interval time.Duration
}
// NewSystemCollector creates a SystemCollector with the given dependencies.
func NewSystemCollector(cmd CommandRunner, fs FileReader, interval time.Duration) *SystemCollector {
return &SystemCollector{cmd: cmd, fs: fs, interval: interval}
}
// Name implements Collector.
func (c *SystemCollector) Name() string { return "system" }
// Interval implements Collector.
func (c *SystemCollector) Interval() time.Duration { return c.interval }
// Collect implements Collector. It merges service data into
// "ietf-system:system-state". DNS is handled reactively by
// fswatcher on /var/lib/misc/resolv.conf. Other system-state
// subtrees (platform, software, users, hostname, timezone, clock,
// memory, load, filesystems) are populated by boot-once, reactive,
// or on-demand providers.
func (c *SystemCollector) Collect(ctx context.Context, t *tree.Tree) error {
state := make(map[string]interface{})
c.addServices(ctx, state)
if data, err := json.Marshal(state); err == nil {
t.Merge("ietf-system:system-state", data)
}
return nil
}
func (c *SystemCollector) addServices(ctx context.Context, state map[string]interface{}) {
out, err := c.cmd.Run(ctx, "initctl", "-j")
if err != nil {
return
}
var initData []map[string]interface{}
if json.Unmarshal(out, &initData) != nil {
return
}
var services []interface{}
for _, d := range initData {
pid, ok := d["pid"]
if !ok {
continue
}
identity, ok := d["identity"]
if !ok {
continue
}
svc := map[string]interface{}{
"pid": toInt(pid),
"name": identity,
"status": d["status"],
"description": d["description"],
"statistics": map[string]interface{}{
"memory-usage": strconv.Itoa(toInt(zeroIfNil(d["memory"]))),
"uptime": strconv.Itoa(toInt(zeroIfNil(d["uptime"]))),
"restart-count": toInt(zeroIfNil(d["restarts"])),
},
}
services = append(services, svc)
}
state["infix-system:services"] = map[string]interface{}{
"service": services,
}
}
func yangDateTime(t time.Time) string {
return t.Format("2006-01-02T15:04:05-07:00")
}
func toInt(v interface{}) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case json.Number:
i, _ := n.Int64()
return int(i)
case string:
i, _ := strconv.Atoi(n)
return i
default:
return 0
}
}
func zeroIfNil(v interface{}) interface{} {
if v == nil {
return 0
}
return v
}

Some files were not shown because too many files have changed in this diff Show More