mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-25 18:23:01 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
980b9dac7c | ||
|
|
e32b72d1ee | ||
|
|
62b890f7b6 | ||
|
|
ce4184e048 | ||
|
|
013932ed06 | ||
|
|
8c6f9cec49 | ||
|
|
27dc42622e | ||
|
|
f4de58a54c | ||
|
|
4899279ed2 | ||
|
|
42c8dc0346 | ||
|
|
a2937e13eb | ||
|
|
b35f4df8a5 | ||
|
|
8890274e90 | ||
|
|
127bbdfc4f | ||
|
|
8fb9e9b68e | ||
|
|
7d9319b8e4 | ||
|
|
7f4c4b63ee | ||
|
|
beb2439922 | ||
|
|
7dc1811389 | ||
|
|
01b9c04f95 | ||
|
|
8b60e447f3 | ||
|
|
d90d0d2a9c | ||
|
|
6a669e6d92 | ||
|
|
155f74e968 | ||
|
|
7f0b88be63 | ||
|
|
05d4598ac9 | ||
|
|
79ec51860b | ||
|
|
51aa4d26b7 | ||
|
|
a33214d67f | ||
|
|
cff66cd74c | ||
|
|
b662984f09 | ||
|
|
39114f89dd | ||
|
|
0b193cb5bd | ||
|
|
98022e2e5f | ||
|
|
52c7e4e90e | ||
|
|
f50f6add9c | ||
|
|
5071a1f177 | ||
|
|
ee5499501c | ||
|
|
e9ff748df9 | ||
|
|
bf850f59d7 | ||
|
|
76de3427ff | ||
|
|
6a78def525 | ||
|
|
59fbb26dca | ||
|
|
56b7904d1a | ||
|
|
3e23997289 | ||
|
|
c7ab211e51 | ||
|
|
32bed66267 | ||
|
|
ca7447dfc9 | ||
|
|
dc7a86c604 | ||
|
|
f305bd3527 | ||
|
|
73f6b52a70 | ||
|
|
9e403831b3 | ||
|
|
b40f64492f | ||
|
|
2dffad67b7 | ||
|
|
0cd598d67c | ||
|
|
6ce28a7e40 | ||
|
|
e938c2dce2 | ||
|
|
61a7c85d96 | ||
|
|
72d3777d3f | ||
|
|
02db44bb82 | ||
|
|
4cc9751c7b | ||
|
|
7b45f2589c | ||
|
|
ad67a5aacf | ||
|
|
fdbf00ca76 | ||
|
|
4131167ede | ||
|
|
281adb45b6 | ||
|
|
35f8d6e3d5 | ||
|
|
1e932f09c1 | ||
|
|
6d27f67b37 | ||
|
|
ce4dd2d3e3 | ||
|
|
0deff2515c | ||
|
|
30907b0343 | ||
|
|
89fee60663 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
Submodule buildroot updated: dd816b9233...707ade3a89
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
service <> name:yangerd log:prio:daemon.notice,tag:yangerd \
|
||||
env:-/etc/default/yangerd \
|
||||
[2345] yangerd -- Operational data daemon
|
||||
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Build artifacts (root-level binaries only; do not match cmd/ source dirs)
|
||||
/yangerd
|
||||
/yangerctl
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user