yangerd: Initial

This commit is contained in:
Mattias Walström
2026-06-27 08:39:47 +02:00
parent 6d27f67b37
commit 1e932f09c1
783 changed files with 321742 additions and 0 deletions
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -129,6 +129,7 @@ BR2_PACKAGE_NETD=y
BR2_PACKAGE_CONFD_TEST_MODE=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
BR2_PACKAGE_YANGERD=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
+1
View File
@@ -181,6 +181,7 @@ BR2_PACKAGE_CONFD=y
BR2_PACKAGE_NETD=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
BR2_PACKAGE_YANGERD=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
+1
View File
@@ -156,6 +156,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
+1
View File
@@ -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
+1
View File
@@ -13,6 +13,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/curios-httpd/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/curios-nftables/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/gencert/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/statd/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/yangerd/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/factory/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/faux/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/finit/Config.in"
+7
View File
@@ -0,0 +1,7 @@
config BR2_PACKAGE_YANGERD
bool "yangerd"
depends on BR2_PACKAGE_HOST_GO_TARGET_ARCH_SUPPORTS
help
Operational data daemon for YANG/NETCONF/RESTCONF.
Replaces Python yanger scripts with a persistent Go daemon
serving operational data over a Unix socket IPC protocol.
+3
View File
@@ -0,0 +1,3 @@
service <!> name:yangerd log:prio:daemon.notice,tag:yangerd \
env:-/etc/default/yangerd \
[S12345] yangerd -- Operational data daemon
+34
View File
@@ -0,0 +1,34 @@
################################################################################
#
# 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
endef
YANGERD_POST_INSTALL_TARGET_HOOKS += YANGERD_INSTALL_EXTRA
$(eval $(golang-package))
+3
View File
@@ -0,0 +1,3 @@
# Build artifacts
yangerd
yangerctl
+27
View File
@@ -0,0 +1,27 @@
Copyright (c) 2025 The KernelKit Authors
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of copyright holders nor the names of
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+23
View File
@@ -0,0 +1,23 @@
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/osrg/gobgp/v3 v3.37.0
github.com/vishvananda/netlink v1.3.1
)
require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.3.0 // indirect
golang.org/x/sys v0.35.0 // indirect
)
+46
View File
@@ -0,0 +1,46 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
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/osrg/gobgp/v3 v3.37.0 h1:+ObuOdvj7G7nxrT0fKFta+EAupdWf/q1WzbXydr8IOY=
github.com/osrg/gobgp/v3 v3.37.0/go.mod h1:kVHVFy1/fyZHJ8P32+ctvPeJogn9qKwa1YCeMRXXrP0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,201 @@
// 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.
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"
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
stdout *bufio.Scanner
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.stdout = bufio.NewScanner(stdout)
b.stdout.Buffer(make([]byte, 0, 4*1024*1024), 4*1024*1024)
b.stderr = stderr
b.alive.Store(true)
b.mu.Unlock()
go b.drainStderr()
return nil
}
// 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)
}
if !b.stdout.Scan() {
b.alive.Store(false)
if err := b.stdout.Err(); err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return nil, fmt.Errorf("bridge batch process exited")
}
raw := make([]byte, len(b.stdout.Bytes()))
copy(raw, b.stdout.Bytes())
return json.RawMessage(raw), nil
}
// 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
}
}
+192
View File
@@ -0,0 +1,192 @@
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
}
installer := make(map[string]interface{})
instOut, err := cmd.Run(ctx, "rauc-installation-status")
if err == nil {
var instData map[string]interface{}
if json.Unmarshal(instOut, &instData) == nil {
if op, ok := instData["operation"]; ok && op != "" {
installer["operation"] = op
}
if prog, ok := instData["progress"].(map[string]interface{}); ok {
progress := make(map[string]interface{})
if pct, ok := prog["percentage"]; ok {
progress["percentage"] = toInt(pct)
}
if msg, ok := prog["message"]; ok {
progress["message"] = msg
}
installer["progress"] = progress
}
}
}
software["installer"] = installer
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
}
+257
View File
@@ -0,0 +1,257 @@
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
}
}
}
}
]
}`
testRaucInstallStatus = `{
"operation": "idle",
"progress": {
"percentage": 100,
"message": "Installation complete"
}
}`
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),
"rauc-installation-status": []byte(testRaucInstallStatus),
"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"])
}
installer, ok := sw["installer"].(map[string]interface{})
if !ok {
t.Fatal("missing installer")
}
if installer["operation"] != "idle" {
t.Fatalf("installer operation: expected 'idle', got %v", installer["operation"])
}
}
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"),
"rauc-installation-status": 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")
}
if _, ok := sw["installer"]; !ok {
t.Fatal("installer key should always be present")
}
}
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,51 @@
// 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) {
for _, c := range collectors {
wg.Add(1)
go runOne(ctx, wg, t, c)
}
}
func runOne(ctx context.Context, wg *sync.WaitGroup, t *tree.Tree, c Collector) {
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)
}
}
}
}
@@ -0,0 +1,466 @@
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 {
containers := []interface{}{}
psList := c.podmanPS(ctx)
for _, ps := range psList {
cont := c.container(ctx, ps)
if cont != nil {
containers = append(containers, cont)
}
}
out := map[string]interface{}{
"container": containers,
}
if data, err := json.Marshal(out); err == nil {
t.Set("infix-containers:containers", data)
}
return nil
}
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"]),
}
cmd := strings.Join(asStringSlice(ps["Command"]), " ")
if cmd != "" {
out["command"] = cmd
}
inspect := c.podmanInspect(ctx, name)
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,445 @@
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(`[{}]`),
"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["command"] != "nginx -g daemon off;" {
t.Fatalf("command mismatch: %v", c["command"])
}
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{}}
out := collectContainers(t, runner, fs)
containers := containerList(t, out)
if len(containers) != 0 {
t.Fatalf("expected no containers when podman ps fails, got %d", len(containers))
}
}
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)
}
}
}
+136
View File
@@ -0,0 +1,136 @@
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.
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
}
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
}
+190
View File
@@ -0,0 +1,190 @@
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")
}
}
+527
View File
@@ -0,0 +1,527 @@
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 {
return nil
}
if data, err := json.Marshal(ntp); err == nil {
t.Set("ietf-ntp:ntp", data)
}
// Populate the Infix NTP sources augmentation under system-state.
if sources := c.addSources(sourcesOut); sources != nil {
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 < 1 || 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
}
+392
View File
@@ -0,0 +1,392 @@
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 (#) minus stratum-0 (10.0.0.4) = 3
if len(sources) != 3 {
t.Fatalf("expected 3 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"])
}
}
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)
raw := tr.Get("ietf-system:system-state")
if raw != nil {
t.Fatal("should not set system-state when no sources available")
}
}
func TestNTPClockStateSynchronized(t *testing.T) {
out, _ := ntpCollect(t, fullNTPRunner())
cs := out["clock-state"].(map[string]interface{})
ss := cs["system-status"].(map[string]interface{})
if ss["clock-state"] != "ietf-ntp:synchronized" {
t.Fatalf("clock-state: expected synchronized, got %v", ss["clock-state"])
}
if toInt(ss["clock-stratum"]) != 2 {
t.Fatalf("clock-stratum: expected 2, got %v", ss["clock-stratum"])
}
// refid from name "router.local" → padded/truncated to 4 chars: "rout"
if ss["clock-refid"] != "rout" {
t.Fatalf("clock-refid: expected 'rout', got %v", ss["clock-refid"])
}
if ss["sync-state"] != "ietf-ntp:clock-synchronized" {
t.Fatalf("sync-state: expected clock-synchronized, got %v", ss["sync-state"])
}
if toInt(ss["clock-precision"]) != -20 {
t.Fatalf("clock-precision: expected -20, got %v", ss["clock-precision"])
}
// Verify nominal/actual freq strings
if ss["nominal-freq"] != "1000000000.0000" {
t.Fatalf("nominal-freq: expected '1000000000.0000', got %v", ss["nominal-freq"])
}
// Infix augmentations
if ss["infix-ntp:update-interval"] != "64.0" {
t.Fatalf("update-interval: expected '64.0', got %v", ss["infix-ntp:update-interval"])
}
// Reference time should be an ISO timestamp
refTime, ok := ss["reference-time"].(string)
if !ok || !strings.HasPrefix(refTime, "2023-") {
t.Fatalf("reference-time should be 2023-* ISO timestamp, got %v", ss["reference-time"])
}
}
func TestNTPClockStateUnsynchronized(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"chronyc -c tracking": []byte(testChronycTrackingUnsync),
},
Errors: map[string]error{},
}
c := newNTPCollector(runner)
tr := tree.New()
c.Collect(context.Background(), tr)
raw := tr.Get("ietf-ntp:ntp")
if raw == nil {
t.Fatal("expected ietf-ntp:ntp even when unsynchronized")
}
var out map[string]interface{}
json.Unmarshal(raw, &out)
cs := out["clock-state"].(map[string]interface{})
ss := cs["system-status"].(map[string]interface{})
if ss["clock-state"] != "ietf-ntp:unsynchronized" {
t.Fatalf("clock-state: expected unsynchronized, got %v", ss["clock-state"])
}
// Stratum 0 → 16
if toInt(ss["clock-stratum"]) != 16 {
t.Fatalf("clock-stratum: expected 16 (mapped from 0), got %v", ss["clock-stratum"])
}
if ss["sync-state"] != "ietf-ntp:clock-never-set" {
t.Fatalf("sync-state: expected clock-never-set, got %v", ss["sync-state"])
}
}
func TestNTPServerPort(t *testing.T) {
out, _ := ntpCollect(t, fullNTPRunner())
// Should find port 123 from the non-loopback ss line
if toInt(out["port"]) != 123 {
t.Fatalf("port: expected 123, got %v", out["port"])
}
}
func TestNTPRefclockMaster(t *testing.T) {
out, _ := ntpCollect(t, fullNTPRunner())
master := out["refclock-master"].(map[string]interface{})
if toInt(master["master-stratum"]) != 2 {
t.Fatalf("master-stratum: expected 2, got %v", master["master-stratum"])
}
}
func TestNTPServerStats(t *testing.T) {
out, _ := ntpCollect(t, fullNTPRunner())
stats := out["ntp-statistics"].(map[string]interface{})
if toInt(stats["packet-received"]) != 1000 {
t.Fatalf("packet-received: expected 1000, got %v", stats["packet-received"])
}
if toInt(stats["packet-dropped"]) != 5 {
t.Fatalf("packet-dropped: expected 5, got %v", stats["packet-dropped"])
}
if toInt(stats["packet-sent"]) != 950 {
t.Fatalf("packet-sent: expected 950, got %v", stats["packet-sent"])
}
if toInt(stats["packet-sent-fail"]) != 10 {
t.Fatalf("packet-sent-fail: expected 10, got %v", stats["packet-sent-fail"])
}
}
func TestNTPAllCommandsFail(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{},
Errors: map[string]error{},
}
c := newNTPCollector(runner)
tr := tree.New()
err := c.Collect(context.Background(), tr)
if err != nil {
t.Fatalf("Collect should not error when chronyc unavailable: %v", err)
}
if tr.Get("ietf-ntp:ntp") != nil {
t.Fatal("should not set ietf-ntp:ntp when nothing to report")
}
}
func TestNTPRefidHexToIPv4(t *testing.T) {
// When refid name is empty, hex ref-ID should be converted to dotted notation
runner := &testutil.MockRunner{
Results: map[string][]byte{
"chronyc -c tracking": []byte("C0A80101,,2,1700000000.0,0.000001,0.000000,0.000000,-1.0,0.0,0.0,0.001,0.001,64.0,Normal"),
},
Errors: map[string]error{},
}
c := newNTPCollector(runner)
tr := tree.New()
c.Collect(context.Background(), tr)
var out map[string]interface{}
json.Unmarshal(tr.Get("ietf-ntp:ntp"), &out)
cs := out["clock-state"].(map[string]interface{})
ss := cs["system-status"].(map[string]interface{})
// C0A80101 → 192.168.1.1
if ss["clock-refid"] != "192.168.1.1" {
t.Fatalf("clock-refid: expected '192.168.1.1', got %v", ss["clock-refid"])
}
}
func TestSplitLines(t *testing.T) {
input := "line1\n\nline2\n \nline3\n"
got := splitLines(input)
if len(got) != 3 {
t.Fatalf("expected 3 lines, got %d: %v", len(got), got)
}
if got[0] != "line1" || got[1] != "line2" || got[2] != "line3" {
t.Fatalf("unexpected lines: %v", got)
}
}
+748
View File
@@ -0,0 +1,748 @@
package collector
import (
"context"
"encoding/json"
"regexp"
"strconv"
"strings"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
// RoutingCollector gathers ietf-routing operational data by merging
// OSPF, RIP, and BFD control-plane protocols into a single tree key.
// Each protocol contributes entries to the control-plane-protocol list
// under ietf-routing:routing.
type RoutingCollector struct {
cmd CommandRunner
interval time.Duration
}
// NewRoutingCollector creates a RoutingCollector with the given dependencies.
func NewRoutingCollector(cmd CommandRunner, interval time.Duration) *RoutingCollector {
return &RoutingCollector{cmd: cmd, interval: interval}
}
// Name implements Collector.
func (c *RoutingCollector) Name() string { return "routing" }
// Interval implements Collector.
func (c *RoutingCollector) Interval() time.Duration { return c.interval }
// Collect implements Collector. It produces one tree key:
// "ietf-routing:routing" containing merged OSPF, RIP, and BFD data.
func (c *RoutingCollector) Collect(ctx context.Context, t *tree.Tree) error {
var protocols []interface{}
if p := c.collectOSPF(ctx); p != nil {
protocols = append(protocols, p)
}
if p := c.collectRIP(ctx); p != nil {
protocols = append(protocols, p)
}
if p := c.collectBFD(ctx); p != nil {
protocols = append(protocols, p)
}
if len(protocols) == 0 {
return nil
}
routing := map[string]interface{}{
"control-plane-protocols": map[string]interface{}{
"control-plane-protocol": protocols,
},
}
if data, err := json.Marshal(routing); err == nil {
t.Merge("ietf-routing:routing", data)
}
return nil
}
// --- OSPF ---
var ospfIfaceStateMap = map[string]string{
"DependUpon": "down",
"Down": "down",
"Waiting": "waiting",
"Loopback": "loopback",
"Point-To-Point": "point-to-point",
"DROther": "dr-other",
"Backup": "bdr",
"DR": "dr",
}
func frrToIETFNeighborState(state string) string {
parts := strings.SplitN(state, "/", 2)
s := parts[0]
if s == "TwoWay" {
return "2-way"
}
return strings.ToLower(s)
}
func frrToIETFNeighborRole(role string) string {
if role == "Backup" {
return "BDR"
}
return role
}
func ospfNetworkType(nt string, p2mpNonBroadcast bool) string {
switch nt {
case "POINTOPOINT":
return "point-to-point"
case "BROADCAST":
return "broadcast"
case "POINTOMULTIPOINT":
if p2mpNonBroadcast {
return "point-to-multipoint"
}
return "hybrid"
case "NBMA":
return "non-broadcast"
default:
return ""
}
}
func (c *RoutingCollector) collectOSPF(ctx context.Context) interface{} {
out, err := c.cmd.Run(ctx, "/usr/libexec/statd/ospf-status")
if err != nil {
return nil
}
var data map[string]interface{}
if json.Unmarshal(out, &data) != nil || len(data) == 0 {
return nil
}
ospf := map[string]interface{}{
"ietf-ospf:areas": map[string]interface{}{},
}
if rid, ok := data["routerId"]; ok {
ospf["ietf-ospf:router-id"] = rid
}
ospf["ietf-ospf:address-family"] = "ipv4"
var areas []interface{}
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
}
var interfaces []interface{}
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
}
}
var neighbors []interface{}
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
}
var nexthops []interface{}
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")
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package collector
import (
"context"
"os"
"os/exec"
"path/filepath"
)
// 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)
}
// ExecRunner is the production CommandRunner using os/exec.
type ExecRunner struct{}
// Run executes name with args and returns combined stdout.
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{}
// ReadFile reads the named file.
func (OSFileReader) ReadFile(path string) ([]byte, error) {
return os.ReadFile(path)
}
// Glob returns filenames matching the pattern.
func (OSFileReader) Glob(pattern string) ([]string, error) {
return filepath.Glob(pattern)
}
+193
View File
@@ -0,0 +1,193 @@
package collector
import (
"context"
"encoding/json"
"net"
"strconv"
"strings"
"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 DNS and service data into
// "ietf-system:system-state". 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.addDNS(ctx, state)
c.addServices(ctx, state)
if data, err := json.Marshal(state); err == nil {
t.Merge("ietf-system:system-state", data)
}
return nil
}
func (c *SystemCollector) addDNS(ctx context.Context, state map[string]interface{}) {
dns := make(map[string]interface{})
servers := make([]interface{}, 0)
var search []string
options := make(map[string]interface{})
headData, err := c.fs.ReadFile("/etc/resolv.conf.head")
if err == nil {
for _, line := range strings.Split(string(headData), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "nameserver") {
ip := strings.TrimSpace(strings.TrimPrefix(line, "nameserver"))
if net.ParseIP(ip) != nil {
servers = append(servers, map[string]interface{}{
"address": ip,
"origin": "static",
})
}
} else if strings.HasPrefix(line, "search") {
search = append(search, strings.Fields(line)[1:]...)
} else if strings.HasPrefix(line, "options") {
for _, opt := range strings.Fields(line)[1:] {
if strings.HasPrefix(opt, "timeout:") {
if v, err := strconv.Atoi(strings.TrimPrefix(opt, "timeout:")); err == nil {
options["timeout"] = v
}
} else if strings.HasPrefix(opt, "attempts:") {
if v, err := strconv.Atoi(strings.TrimPrefix(opt, "attempts:")); err == nil {
options["attempts"] = v
}
}
}
}
}
}
resolvOut, err := c.cmd.Run(ctx, "/sbin/resolvconf", "-l")
if err == nil {
for _, line := range strings.Split(string(resolvOut), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "nameserver") {
hashParts := strings.SplitN(line, "#", 2)
ip := strings.TrimSpace(strings.TrimPrefix(hashParts[0], "nameserver"))
if net.ParseIP(ip) == nil {
continue
}
entry := map[string]interface{}{
"address": ip,
"origin": "dhcp",
}
if len(hashParts) > 1 {
entry["interface"] = strings.TrimSpace(hashParts[1])
}
servers = append(servers, entry)
} else if strings.HasPrefix(line, "search") {
hashParts := strings.SplitN(line, "#", 2)
search = append(search, strings.Fields(hashParts[0])[1:]...)
}
}
}
if len(options) > 0 {
dns["options"] = options
}
dns["server"] = servers
if len(search) > 0 {
dns["search"] = search
}
state["infix-system:dns-resolver"] = dns
}
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
}
@@ -0,0 +1,293 @@
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"
)
const (
testResolvHead = `nameserver 8.8.8.8
nameserver 1.1.1.1
search example.com local.lan
options timeout:2 attempts:3
`
testResolvconfOutput = `nameserver 10.0.0.1 # eth0
nameserver 10.0.0.2 # eth1
search dhcp.example.com # eth0
`
testInitctlJSON = `[
{
"identity": "sshd",
"pid": 123,
"status": "running",
"description": "OpenSSH daemon",
"memory": 4096000,
"uptime": 3600,
"restarts": 2
},
{
"identity": "sysklogd",
"pid": 456,
"status": "running",
"description": "System logger",
"memory": 2048000,
"uptime": 7200,
"restarts": 0
}
]`
)
func newTestCollector() (*SystemCollector, *testutil.MockRunner, *testutil.MockFileReader) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"initctl -j": []byte(testInitctlJSON),
"/sbin/resolvconf -l": []byte(testResolvconfOutput),
},
Errors: map[string]error{},
}
fs := &testutil.MockFileReader{
Files: map[string][]byte{
"/etc/resolv.conf.head": []byte(testResolvHead),
},
Globs: map[string][]string{},
}
c := NewSystemCollector(runner, fs, 60*time.Second)
return c, runner, fs
}
func collectToState(t *testing.T, c *SystemCollector) map[string]interface{} {
t.Helper()
tr := tree.New()
if err := c.Collect(context.Background(), tr); err != nil {
t.Fatalf("Collect failed: %v", err)
}
stateRaw := tr.Get("ietf-system:system-state")
if stateRaw == nil {
t.Fatal("missing ietf-system:system-state in tree")
}
state := make(map[string]interface{})
if err := json.Unmarshal(stateRaw, &state); err != nil {
t.Fatalf("unmarshal system-state: %v", err)
}
return state
}
func TestSystemCollectorName(t *testing.T) {
c, _, _ := newTestCollector()
if c.Name() != "system" {
t.Fatalf("expected name 'system', got %q", c.Name())
}
}
func TestSystemCollectorInterval(t *testing.T) {
c, _, _ := newTestCollector()
if c.Interval() != 60*time.Second {
t.Fatalf("expected interval 60s, got %v", c.Interval())
}
}
func TestSystemCollectorDNS(t *testing.T) {
c, _, _ := newTestCollector()
state := collectToState(t, c)
dns, ok := state["infix-system:dns-resolver"].(map[string]interface{})
if !ok {
t.Fatal("missing infix-system:dns-resolver in system-state")
}
servers, ok := dns["server"].([]interface{})
if !ok {
t.Fatal("missing server list in dns-resolver")
}
// 2 static (resolv.conf.head) + 2 DHCP (resolvconf -l) = 4 total
if len(servers) != 4 {
t.Fatalf("expected 4 DNS servers, got %d: %v", len(servers), servers)
}
s0 := servers[0].(map[string]interface{})
if s0["address"] != "8.8.8.8" || s0["origin"] != "static" {
t.Fatalf("server[0]: expected 8.8.8.8/static, got %v", s0)
}
s1 := servers[1].(map[string]interface{})
if s1["address"] != "1.1.1.1" || s1["origin"] != "static" {
t.Fatalf("server[1]: expected 1.1.1.1/static, got %v", s1)
}
s2 := servers[2].(map[string]interface{})
if s2["address"] != "10.0.0.1" || s2["origin"] != "dhcp" {
t.Fatalf("server[2]: expected 10.0.0.1/dhcp, got %v", s2)
}
if s2["interface"] != "eth0" {
t.Fatalf("server[2] interface: expected eth0, got %v", s2["interface"])
}
search, ok := dns["search"].([]interface{})
if !ok || len(search) < 2 {
t.Fatalf("expected search domains, got %v", dns["search"])
}
options, ok := dns["options"].(map[string]interface{})
if !ok {
t.Fatal("missing options in dns-resolver")
}
if int(options["timeout"].(float64)) != 2 {
t.Fatalf("dns timeout: expected 2, got %v", options["timeout"])
}
if int(options["attempts"].(float64)) != 3 {
t.Fatalf("dns attempts: expected 3, got %v", options["attempts"])
}
}
func TestSystemCollectorServices(t *testing.T) {
c, _, _ := newTestCollector()
state := collectToState(t, c)
svcs, ok := state["infix-system:services"].(map[string]interface{})
if !ok {
t.Fatal("missing infix-system:services in system-state")
}
serviceList, ok := svcs["service"].([]interface{})
if !ok || len(serviceList) != 2 {
t.Fatalf("expected 2 services, got %v", svcs["service"])
}
svc0 := serviceList[0].(map[string]interface{})
if svc0["name"] != "sshd" {
t.Fatalf("service[0] name: expected sshd, got %v", svc0["name"])
}
if int(svc0["pid"].(float64)) != 123 {
t.Fatalf("service[0] pid: expected 123, got %v", svc0["pid"])
}
stats := svc0["statistics"].(map[string]interface{})
if stats["memory-usage"] != "4096000" {
t.Fatalf("service[0] memory-usage: expected '4096000', got %v", stats["memory-usage"])
}
if stats["uptime"] != "3600" {
t.Fatalf("service[0] uptime: expected '3600', got %v", stats["uptime"])
}
if int(stats["restart-count"].(float64)) != 2 {
t.Fatalf("service[0] restart-count: expected 2, got %v", stats["restart-count"])
}
}
func TestSystemCollectorCommandFailureGraceful(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{},
Errors: map[string]error{
"initctl -j": fmt.Errorf("not available"),
"/sbin/resolvconf -l": fmt.Errorf("not available"),
},
}
fs := &testutil.MockFileReader{
Files: map[string][]byte{},
Globs: map[string][]string{},
}
c := NewSystemCollector(runner, fs, 60*time.Second)
tr := tree.New()
err := c.Collect(context.Background(), tr)
if err != nil {
t.Fatalf("Collect should not return error on partial failures: %v", err)
}
if tr.Get("ietf-system:system-state") == nil {
t.Fatal("ietf-system:system-state should be set even with command failures")
}
}
func TestSystemCollectorTreeKeys(t *testing.T) {
c, _, _ := newTestCollector()
tr := tree.New()
c.Collect(context.Background(), tr)
keys := tr.Keys()
if len(keys) != 1 {
t.Fatalf("expected exactly 1 tree key, got %d: %v", len(keys), keys)
}
if keys[0] != "ietf-system:system-state" {
t.Fatalf("expected tree key 'ietf-system:system-state', got %q", keys[0])
}
}
func TestSystemCollectorServicesNilFields(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"initctl -j": []byte(`[{"identity":"minimal","pid":999,"status":"running","description":"Minimal service"}]`),
"/sbin/resolvconf -l": []byte(""),
},
Errors: map[string]error{},
}
fs := &testutil.MockFileReader{
Files: map[string][]byte{},
Globs: map[string][]string{},
}
c := NewSystemCollector(runner, fs, 60*time.Second)
state := collectToState(t, c)
svcs := state["infix-system:services"].(map[string]interface{})
serviceList := svcs["service"].([]interface{})
if len(serviceList) != 1 {
t.Fatalf("expected 1 service, got %d", len(serviceList))
}
svc := serviceList[0].(map[string]interface{})
stats := svc["statistics"].(map[string]interface{})
if stats["memory-usage"] != "0" {
t.Fatalf("nil memory should become '0', got %v", stats["memory-usage"])
}
if stats["uptime"] != "0" {
t.Fatalf("nil uptime should become '0', got %v", stats["uptime"])
}
if int(stats["restart-count"].(float64)) != 0 {
t.Fatalf("nil restarts should become 0, got %v", stats["restart-count"])
}
}
func TestSystemCollectorNoDNSEmptyArray(t *testing.T) {
runner := &testutil.MockRunner{
Results: map[string][]byte{
"initctl -j": []byte(testInitctlJSON),
"/sbin/resolvconf -l": []byte(""),
},
Errors: map[string]error{},
}
fs := &testutil.MockFileReader{
Files: map[string][]byte{},
Globs: map[string][]string{},
}
c := NewSystemCollector(runner, fs, 60*time.Second)
state := collectToState(t, c)
dns, ok := state["infix-system:dns-resolver"].(map[string]interface{})
if !ok {
t.Fatal("missing infix-system:dns-resolver")
}
servers, ok := dns["server"].([]interface{})
if !ok {
t.Fatal("dns server should be an array, not null")
}
if len(servers) != 0 {
t.Fatalf("expected empty server array, got %d servers", len(servers))
}
}
+81
View File
@@ -0,0 +1,81 @@
package config
import (
"os"
"strconv"
"time"
)
// Config holds all yangerd runtime configuration, populated from
// environment variables with sensible defaults.
type Config struct {
Socket string
LogLevel string
ZebraSocket string
LLDPCommand string
StartupTimeout time.Duration
PollSystem time.Duration
PollRouting time.Duration
PollNTP time.Duration
PollHardware time.Duration
PollContainers time.Duration
EnableWifi bool
EnableLLDP bool
EnableFirewall bool
EnableDHCP bool
EnableContainers bool
EnableGPS bool
}
// Load reads configuration from the environment.
func Load() *Config {
return &Config{
Socket: envStr("YANGERD_SOCKET", "/run/yangerd.sock"),
LogLevel: envStr("YANGERD_LOG_LEVEL", "info"),
ZebraSocket: envStr("YANGERD_ZEBRA_SOCKET", "/var/run/frr/zserv.api"),
LLDPCommand: envStr("YANGERD_LLDP_COMMAND", "lldpcli"),
StartupTimeout: envDur("YANGERD_STARTUP_TIMEOUT", 5*time.Second),
PollSystem: envDur("YANGERD_POLL_INTERVAL_SYSTEM", 60*time.Second),
PollRouting: envDur("YANGERD_POLL_INTERVAL_ROUTING", 10*time.Second),
PollNTP: envDur("YANGERD_POLL_INTERVAL_NTP", 60*time.Second),
PollHardware: envDur("YANGERD_POLL_INTERVAL_HARDWARE", 10*time.Second),
PollContainers: envDur("YANGERD_POLL_INTERVAL_CONTAINERS", 10*time.Second),
EnableWifi: envBool("YANGERD_ENABLE_WIFI", false),
EnableLLDP: envBool("YANGERD_ENABLE_LLDP", true),
EnableFirewall: envBool("YANGERD_ENABLE_FIREWALL", true),
EnableDHCP: envBool("YANGERD_ENABLE_DHCP", true),
EnableContainers: envBool("YANGERD_ENABLE_CONTAINERS", false),
EnableGPS: envBool("YANGERD_ENABLE_GPS", false),
}
}
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envBool(key string, def bool) bool {
v := os.Getenv(key)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
func envDur(key string, def time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return def
}
d, err := time.ParseDuration(v)
if err != nil {
return def
}
return d
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,690 @@
package dbusmonitor
import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"time"
)
func TestParseDnsmasqLeases(t *testing.T) {
tests := []struct {
name string
input string
want []map[string]any
}{
{
name: "normal lease line",
input: "1711900000 aa:bb:cc:dd:ee:ff 192.168.1.100 myhost 01:aa:bb:cc:dd:ee:ff",
want: []map[string]any{{
"expires": time.Unix(1711900000, 0).UTC().Format(time.RFC3339),
"address": "192.168.1.100",
"phys-address": "aa:bb:cc:dd:ee:ff",
"hostname": "myhost",
"client-id": "01:aa:bb:cc:dd:ee:ff",
}},
},
{
name: "wildcard hostname and client id",
input: "1711900000 aa:bb:cc:dd:ee:ff 192.168.1.100 * *",
want: []map[string]any{{
"expires": time.Unix(1711900000, 0).UTC().Format(time.RFC3339),
"address": "192.168.1.100",
"phys-address": "aa:bb:cc:dd:ee:ff",
"hostname": "",
"client-id": "",
}},
},
{
name: "never expiring lease",
input: "0 aa:bb:cc:dd:ee:ff 192.168.1.100 host *",
want: []map[string]any{{
"expires": "never",
"address": "192.168.1.100",
"phys-address": "aa:bb:cc:dd:ee:ff",
"hostname": "host",
"client-id": "",
}},
},
{
name: "multiple leases with malformed lines skipped",
input: "1711900000 aa:bb:cc:dd:ee:ff 192.168.1.100 myhost 01:aa:bb:cc:dd:ee:ff\n" +
"bad line with too few fields\n" +
"1711900100 11:22:33:44:55:66 192.168.1.101 host2 *\n",
want: []map[string]any{
{
"expires": time.Unix(1711900000, 0).UTC().Format(time.RFC3339),
"address": "192.168.1.100",
"phys-address": "aa:bb:cc:dd:ee:ff",
"hostname": "myhost",
"client-id": "01:aa:bb:cc:dd:ee:ff",
},
{
"expires": time.Unix(1711900100, 0).UTC().Format(time.RFC3339),
"address": "192.168.1.101",
"phys-address": "11:22:33:44:55:66",
"hostname": "host2",
"client-id": "",
},
},
},
{
name: "empty input",
input: "",
want: []map[string]any{},
},
{
name: "invalid timestamp skipped",
input: "abc aa:bb:cc:dd:ee:ff 192.168.1.100 host *",
want: []map[string]any{},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := parseDnsmasqLeases(tc.input)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("parseDnsmasqLeases() mismatch\nwant: %#v\n got: %#v", tc.want, got)
}
})
}
}
func TestBuildDHCPTree(t *testing.T) {
tests := []struct {
name string
leases []map[string]any
stats map[string]any
check func(t *testing.T, root map[string]any)
}{
{
name: "with leases and stats",
leases: []map[string]any{{
"expires": "never",
"address": "192.168.1.100",
"phys-address": "aa:bb:cc:dd:ee:ff",
"hostname": "host",
"client-id": "",
}},
stats: map[string]any{"out-offers": 3, "in-requests": 4},
check: func(t *testing.T, root map[string]any) {
t.Helper()
stats, ok := root["statistics"].(map[string]any)
if !ok {
t.Fatalf("missing statistics map")
}
if stats["out-offers"] != float64(3) || stats["in-requests"] != float64(4) {
t.Fatalf("unexpected statistics: %#v", stats)
}
leasesNode, ok := root["leases"].(map[string]any)
if !ok {
t.Fatalf("missing leases map")
}
leaseList, ok := leasesNode["lease"].([]any)
if !ok || len(leaseList) != 1 {
t.Fatalf("unexpected lease list: %#v", leasesNode["lease"])
}
lease, ok := leaseList[0].(map[string]any)
if !ok || lease["address"] != "192.168.1.100" {
t.Fatalf("unexpected lease entry: %#v", leaseList[0])
}
},
},
{
name: "with empty leases",
leases: []map[string]any{},
stats: map[string]any{"out-offers": 0},
check: func(t *testing.T, root map[string]any) {
t.Helper()
leasesNode := root["leases"].(map[string]any)
leaseList, ok := leasesNode["lease"].([]any)
if !ok || len(leaseList) != 0 {
t.Fatalf("expected empty lease list, got %#v", leasesNode["lease"])
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
raw := buildDHCPTree(tc.leases, tc.stats)
var root map[string]any
if err := json.Unmarshal(raw, &root); err != nil {
t.Fatalf("unmarshal buildDHCPTree output: %v", err)
}
tc.check(t, root)
})
}
}
func TestBuildFirewallTree(t *testing.T) {
tests := []struct {
name string
defaultZ string
logDenied string
lockdown bool
zones []map[string]any
policies []map[string]any
services []map[string]any
expectKeys map[string]bool
}{
{
name: "with zones policies and services",
defaultZ: "public",
logDenied: "all",
lockdown: true,
zones: []map[string]any{{"name": "public"}},
policies: []map[string]any{{"name": "default-drop"}},
services: []map[string]any{{"name": "ssh"}},
expectKeys: map[string]bool{
"zone": true,
"policy": true,
"service": true,
},
},
{
name: "omits empty zone policy service keys",
defaultZ: "trusted",
logDenied: "off",
lockdown: false,
expectKeys: map[string]bool{
"zone": false,
"policy": false,
"service": false,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
raw := buildFirewallTree(tc.defaultZ, tc.logDenied, tc.lockdown, tc.zones, tc.policies, tc.services)
var root map[string]any
if err := json.Unmarshal(raw, &root); err != nil {
t.Fatalf("unmarshal buildFirewallTree output: %v", err)
}
if root["default"] != tc.defaultZ || root["logging"] != tc.logDenied || root["lockdown"] != tc.lockdown {
t.Fatalf("default/logging/lockdown mismatch: %#v", root)
}
for k, shouldExist := range tc.expectKeys {
_, exists := root[k]
if exists != shouldExist {
t.Fatalf("key %q exists=%v, want %v", k, exists, shouldExist)
}
}
})
}
}
func TestParseServicePorts(t *testing.T) {
tests := []struct {
name string
settings map[string]any
want []map[string]any
}{
{
name: "single port",
settings: map[string]any{"ports": []any{[]any{"80", "tcp"}}},
want: []map[string]any{{"proto": "tcp", "lower": 80}},
},
{
name: "port range",
settings: map[string]any{"ports": []any{[]any{"8080-8090", "tcp"}}},
want: []map[string]any{{"proto": "tcp", "lower": 8080, "upper": 8090}},
},
{
name: "multiple ports",
settings: map[string]any{"ports": []any{
[]any{"80", "tcp"},
[]any{"53", "udp"},
}},
want: []map[string]any{
{"proto": "tcp", "lower": 80},
{"proto": "udp", "lower": 53},
},
},
{
name: "missing ports",
settings: map[string]any{},
want: []map[string]any{},
},
{
name: "empty ports",
settings: map[string]any{"ports": []any{}},
want: []map[string]any{},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := parseServicePorts(tc.settings)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("parseServicePorts mismatch\nwant: %#v\n got: %#v", tc.want, got)
}
})
}
}
func TestParsePolicyCustomFilters(t *testing.T) {
tests := []struct {
name string
rules []string
want []map[string]any
}{
{
name: "rich rule icmp type accept",
rules: []string{`rule priority="0" family="ipv4" icmp-type name="echo-request" accept`},
want: []map[string]any{{
"name": "icmp-echo-request",
"priority": 0,
"family": "ipv4",
"action": "accept",
"icmp": map[string]any{"type": "echo-request"},
}},
},
{
name: "rich rule icmp block reject",
rules: []string{`rule family="ipv6" icmp-block name="router-advertisement" reject`},
want: []map[string]any{{
"name": "icmp-router-advertisement",
"priority": -1,
"family": "ipv6",
"action": "reject",
"icmp": map[string]any{"type": "router-advertisement"},
}},
},
{
name: "rule without icmp skipped",
rules: []string{`rule family="ipv4" service name="ssh" accept`},
want: []map[string]any{},
},
{
name: "multiple rules include only icmp",
rules: []string{
`rule priority="10" family="ipv4" icmp-type name="echo-reply" drop`,
`rule family="ipv4" service name="http" accept`,
`rule family="ipv6" icmp-block name="router-advertisement" reject`,
},
want: []map[string]any{
{
"name": "icmp-echo-reply",
"priority": 10,
"family": "ipv4",
"action": "drop",
"icmp": map[string]any{"type": "echo-reply"},
},
{
"name": "icmp-router-advertisement",
"priority": -1,
"family": "ipv6",
"action": "reject",
"icmp": map[string]any{"type": "router-advertisement"},
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := parsePolicyCustomFilters(tc.rules)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("parsePolicyCustomFilters mismatch\nwant: %#v\n got: %#v", tc.want, got)
}
})
}
}
func TestGetForwardPorts(t *testing.T) {
tests := []struct {
name string
settings map[string]any
want []map[string]any
wantNil bool
}{
{
name: "single port forward",
settings: map[string]any{"forward_ports": []any{[]any{"80", "tcp", "8080", "192.168.1.1"}}},
want: []map[string]any{{
"proto": "tcp",
"lower": 80,
"to": map[string]any{"addr": "192.168.1.1", "port": 8080},
}},
},
{
name: "port range forward",
settings: map[string]any{"forward_ports": []any{[]any{"1000-1005", "udp", "2000", "10.0.0.2"}}},
want: []map[string]any{{
"proto": "udp",
"lower": 1000,
"upper": 1005,
"to": map[string]any{"addr": "10.0.0.2", "port": 2000},
}},
},
{
name: "missing to port defaults to lower",
settings: map[string]any{"forward_ports": []any{[]any{"8081", "tcp", "", "192.168.1.1"}}},
want: []map[string]any{{
"proto": "tcp",
"lower": 8081,
"to": map[string]any{"addr": "192.168.1.1", "port": 8081},
}},
},
{
name: "missing forward ports",
settings: map[string]any{},
wantNil: true,
},
{
name: "empty forward ports",
settings: map[string]any{"forward_ports": []any{}},
want: []map[string]any{},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := getForwardPorts(tc.settings)
if tc.wantNil {
if got != nil {
t.Fatalf("expected nil, got %#v", got)
}
return
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("getForwardPorts mismatch\nwant: %#v\n got: %#v", tc.want, got)
}
})
}
}
func TestMapZoneTarget(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "percent reject", in: "%%REJECT%%", want: "reject"},
{name: "reject", in: "REJECT", want: "reject"},
{name: "drop", in: "DROP", want: "drop"},
{name: "accept", in: "ACCEPT", want: "accept"},
{name: "default", in: "DEFAULT", want: "accept"},
{name: "empty", in: "", want: "accept"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := mapZoneTarget(tc.in); got != tc.want {
t.Fatalf("mapZoneTarget(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestMapPolicyTarget(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "continue", in: "CONTINUE", want: "continue"},
{name: "accept", in: "ACCEPT", want: "accept"},
{name: "drop", in: "DROP", want: "drop"},
{name: "reject", in: "REJECT", want: "reject"},
{name: "empty", in: "", want: "reject"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := mapPolicyTarget(tc.in); got != tc.want {
t.Fatalf("mapPolicyTarget(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestAsBool(t *testing.T) {
tests := []struct {
name string
in any
want bool
}{
{name: "bool true", in: true, want: true},
{name: "bool false", in: false, want: false},
{name: "int one", in: 1, want: true},
{name: "int zero", in: 0, want: false},
{name: "int8 one", in: int8(1), want: true},
{name: "int16 zero", in: int16(0), want: false},
{name: "int64 one", in: int64(1), want: true},
{name: "uint32 zero", in: uint32(0), want: false},
{name: "uint64 one", in: uint64(1), want: true},
{name: "string true", in: "true", want: true},
{name: "string false", in: "false", want: false},
{name: "string one", in: "1", want: true},
{name: "string zero", in: "0", want: false},
{name: "string yes", in: "yes", want: true},
{name: "string no", in: "no", want: false},
{name: "string on", in: "on", want: true},
{name: "trim and case", in: " TRUE ", want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := asBool(tc.in); got != tc.want {
t.Fatalf("asBool(%#v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
func TestToUint64(t *testing.T) {
tests := []struct {
name string
in any
want uint64
}{
{name: "uint8", in: uint8(8), want: 8},
{name: "uint16", in: uint16(16), want: 16},
{name: "uint32", in: uint32(32), want: 32},
{name: "uint64", in: uint64(64), want: 64},
{name: "uint", in: uint(7), want: 7},
{name: "int positive", in: 42, want: 42},
{name: "int negative", in: -1, want: 0},
{name: "int64 negative", in: int64(-9), want: 0},
{name: "float64", in: float64(99.9), want: 99},
{name: "float64 negative", in: float64(-0.1), want: 0},
{name: "string number", in: "42", want: 42},
{name: "string invalid", in: "nope", want: 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := toUint64(tc.in); got != tc.want {
t.Fatalf("toUint64(%#v) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
func TestParseHelpers(t *testing.T) {
t.Run("parseQuotedName", func(t *testing.T) {
tests := []struct {
name string
rule string
want string
}{
{name: "extract name", rule: `rule icmp-type name="echo-request" accept`, want: "echo-request"},
{name: "missing name", rule: `rule icmp-type accept`, want: ""},
{name: "unterminated quote", rule: `rule icmp-type name="echo-request accept`, want: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := parseQuotedName(tc.rule); got != tc.want {
t.Fatalf("parseQuotedName(%q) = %q, want %q", tc.rule, got, tc.want)
}
})
}
})
t.Run("parsePriority", func(t *testing.T) {
tests := []struct {
name string
in string
want int
}{
{name: "quoted value", in: `"0" family="ipv4"`, want: 0},
{name: "plain value", in: `10 family="ipv6"`, want: 10},
{name: "empty", in: ``, want: -1},
{name: "invalid", in: `abc family="ipv4"`, want: -1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := parsePriority(tc.in); got != tc.want {
t.Fatalf("parsePriority(%q) = %d, want %d", tc.in, got, tc.want)
}
})
}
})
t.Run("hasImmutableTag", func(t *testing.T) {
tests := []struct {
name string
in string
want bool
}{
{name: "has tag", in: "Public (immutable)", want: true},
{name: "no tag", in: "Public", want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := hasImmutableTag(tc.in); got != tc.want {
t.Fatalf("hasImmutableTag(%q) = %v, want %v", tc.in, got, tc.want)
}
})
}
})
}
func TestSleepOrDone(t *testing.T) {
tests := []struct {
name string
cancelNow bool
delay time.Duration
wantErr bool
}{
{name: "done context returns error", cancelNow: true, delay: time.Millisecond, wantErr: true},
{name: "sleep completes when context active", cancelNow: false, delay: time.Millisecond, wantErr: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
if tc.cancelNow {
cancel()
} else {
defer cancel()
}
err := sleepOrDone(ctx, tc.delay)
if tc.wantErr {
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
return
}
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
})
}
}
func TestDecodeActiveZones(t *testing.T) {
tests := []struct {
name string
in any
want map[string]map[string]any
}{
{
name: "godbus concrete type (a{sa{sas}})",
in: map[string]map[string][]string{
"public": {
"interfaces": {"eth0", "eth1"},
"sources": {"10.0.0.0/8"},
},
"mgmt": {
"interfaces": {"eth2"},
},
},
want: map[string]map[string]any{
"public": {
"interfaces": []string{"eth0", "eth1"},
"sources": []string{"10.0.0.0/8"},
},
"mgmt": {
"interfaces": []string{"eth2"},
},
},
},
{
name: "pre-decoded map[string]map[string]any",
in: map[string]map[string]any{
"home": {
"interfaces": []string{"wlan0"},
},
},
want: map[string]map[string]any{
"home": {
"interfaces": []string{"wlan0"},
},
},
},
{
name: "nil input",
in: nil,
want: map[string]map[string]any{},
},
{
name: "unsupported type",
in: "garbage",
want: map[string]map[string]any{},
},
{
name: "empty map",
in: map[string]map[string][]string{},
want: map[string]map[string]any{},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := decodeActiveZones(tc.in)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("decodeActiveZones() =\n %v\nwant:\n %v", got, tc.want)
}
})
}
}
func TestNextDelay(t *testing.T) {
tests := []struct {
name string
in time.Duration
want time.Duration
}{
{name: "doubles normal delay", in: reconnectInitial, want: reconnectInitial * 2},
{name: "caps at reconnectMax", in: reconnectMax, want: reconnectMax},
{name: "near max also caps", in: reconnectMax - time.Second, want: reconnectMax},
{name: "zero becomes reconnectInitial", in: 0, want: reconnectInitial},
{name: "negative becomes reconnectInitial", in: -time.Second, want: reconnectInitial},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := nextDelay(tc.in); got != tc.want {
t.Fatalf("nextDelay(%v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
@@ -0,0 +1,229 @@
// Package ethmonitor subscribes to ethtool genetlink notifications and
// keeps per-interface ethernet settings updated via a callback.
package ethmonitor
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"github.com/mdlayher/ethtool"
"github.com/mdlayher/genetlink"
"github.com/mdlayher/netlink"
)
const (
// ETHTOOL_MSG_LINKINFO_NTF is the ethtool generic netlink notification
// command for link info changes.
ETHTOOL_MSG_LINKINFO_NTF = 28
// ETHTOOL_MSG_LINKMODES_NTF is the ethtool generic netlink notification
// command for link mode changes.
ETHTOOL_MSG_LINKMODES_NTF = 29
ethtoolFamilyName = "ethtool"
ethtoolMonitorGroupName = "monitor"
nlaHeaderIfindex = 1
)
// EthMonitor listens for ethtool genetlink monitor events and updates
// interface ethernet operational state via a callback.
type EthMonitor struct {
conn *genetlink.Conn
family genetlink.Family
groupID uint32
etClient *ethtool.Client
log *slog.Logger
onUpdate func(ifname string, data json.RawMessage)
}
// New creates an EthMonitor, resolves the ethtool genetlink family,
// and joins its "monitor" multicast group.
func New(log *slog.Logger) (*EthMonitor, error) {
conn, err := genetlink.Dial(nil)
if err != nil {
return nil, fmt.Errorf("dial genetlink: %w", err)
}
family, err := conn.GetFamily(ethtoolFamilyName)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("resolve %q genetlink family: %w", ethtoolFamilyName, err)
}
var groupID uint32
for _, g := range family.Groups {
if g.Name == ethtoolMonitorGroupName {
groupID = g.ID
break
}
}
if groupID == 0 {
_ = conn.Close()
return nil, fmt.Errorf("multicast group %q not found in family %q", ethtoolMonitorGroupName, ethtoolFamilyName)
}
if err := conn.JoinGroup(groupID); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("join ethtool monitor group %d: %w", groupID, err)
}
etClient, err := ethtool.New()
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("create ethtool client: %w", err)
}
return &EthMonitor{
conn: conn,
family: family,
groupID: groupID,
etClient: etClient,
log: log,
}, nil
}
// SetOnUpdate sets the callback invoked when ethernet data changes.
func (m *EthMonitor) SetOnUpdate(fn func(string, json.RawMessage)) {
m.onUpdate = fn
}
// Run starts the ethtool genetlink receive loop and updates interface
// ethernet settings when link info or link mode notifications are seen.
func (m *EthMonitor) Run(ctx context.Context) error {
defer func() {
if err := m.etClient.Close(); err != nil {
m.log.Warn("ethmonitor: close ethtool client", "err", err)
}
if err := m.conn.Close(); err != nil {
m.log.Warn("ethmonitor: close genetlink conn", "err", err)
}
}()
for {
if err := ctx.Err(); err != nil {
return err
}
msgs, _, err := m.conn.Receive()
if err != nil {
if cerr := ctx.Err(); cerr != nil {
return cerr
}
return fmt.Errorf("receive ethtool genetlink message: %w", err)
}
for _, msg := range msgs {
switch msg.Header.Command {
case ETHTOOL_MSG_LINKINFO_NTF, ETHTOOL_MSG_LINKMODES_NTF:
ifname, err := extractIfname(msg.Data)
if err != nil {
m.log.Warn("ethmonitor: extract interface name", "err", err)
continue
}
m.refreshEthernetSettings(ifname)
}
}
}
}
// RefreshInterface refreshes ethernet settings for ifname. It is intended
// to be called by other subsystems (for example nlmonitor RTM_NEWLINK).
func (m *EthMonitor) RefreshInterface(ifname string) {
m.refreshEthernetSettings(ifname)
}
func (m *EthMonitor) refreshEthernetSettings(ifname string) {
iface, err := net.InterfaceByName(ifname)
if err != nil {
m.log.Warn("ethmonitor: lookup interface", "ifname", ifname, "err", err)
return
}
ethIface := ethtool.Interface{Index: iface.Index, Name: iface.Name}
if _, err := m.etClient.LinkInfo(ethIface); err != nil {
m.log.Warn("ethmonitor: query link info", "ifname", ifname, "err", err)
return
}
mode, err := m.etClient.LinkMode(ethIface)
if err != nil {
m.log.Warn("ethmonitor: query link mode", "ifname", ifname, "err", err)
return
}
eth := map[string]any{
"speed": speedString(mode.SpeedMegabits),
"duplex": duplexString(mode.Duplex),
"auto-negotiation": map[string]any{
"enable": mode.Autoneg == ethtool.AutonegOn,
},
}
raw, err := json.Marshal(eth)
if err != nil {
m.log.Warn("ethmonitor: marshal ethernet settings", "ifname", ifname, "err", err)
return
}
if m.onUpdate != nil {
m.onUpdate(ifname, json.RawMessage(raw))
}
}
func extractIfname(data []byte) (string, error) {
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return "", fmt.Errorf("new decoder: %w", err)
}
for ad.Next() {
nested, err := netlink.NewAttributeDecoder(ad.Bytes())
if err != nil {
continue
}
for nested.Next() {
if nested.Type() != nlaHeaderIfindex {
continue
}
ifindex := int(nested.Uint32())
iface, err := net.InterfaceByIndex(ifindex)
if err != nil {
return "", fmt.Errorf("lookup interface index %d: %w", ifindex, err)
}
return iface.Name, nil
}
if err := nested.Err(); err != nil {
return "", fmt.Errorf("decode nested attrs: %w", err)
}
}
if err := ad.Err(); err != nil {
return "", fmt.Errorf("decode attrs: %w", err)
}
return "", fmt.Errorf("header ifindex attribute not found")
}
func duplexString(d ethtool.Duplex) string {
switch d {
case ethtool.Full:
return "full"
case ethtool.Half:
return "half"
default:
return "unknown"
}
}
// speedString converts a speed in megabits to a decimal64 string in
// Gb/s with 3 fraction digits, matching the ieee802-ethernet-interface
// YANG model's eth-if-speed-type (decimal64, fraction-digits 3, units Gb/s).
func speedString(megabits int) string {
return fmt.Sprintf("%.3f", float64(megabits)/1000.0)
}
+255
View File
@@ -0,0 +1,255 @@
// Package fswatcher provides inotify-based reactive monitoring of
// filesystem paths. It replaces polling for procfs files that support
// inotify (e.g. IP forwarding flags). Each watched path has a
// handler that reads the file and updates the tree, with per-path
// debouncing to coalesce burst writes.
package fswatcher
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
// WatchHandler defines the callback for a watched path.
type WatchHandler struct {
TreeKey string
ReadFunc func(path string) (json.RawMessage, error)
Debounce time.Duration
// UseMerge causes the watcher to call tree.Merge instead of
// tree.Set, performing a shallow first-level JSON merge into
// the existing blob at TreeKey.
UseMerge bool
}
// FSWatcher monitors filesystem paths via inotify and updates the
// tree when files change.
type FSWatcher struct {
watcher *fsnotify.Watcher
tree *tree.Tree
handlers map[string]WatchHandler
dirHandlers map[string]WatchHandler // directory path → handler
debounce map[string]*time.Timer
mu sync.Mutex
log *slog.Logger
}
// New creates an FSWatcher backed by an inotify instance.
func New(t *tree.Tree, log *slog.Logger) (*FSWatcher, error) {
w, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("fsnotify: %w", err)
}
return &FSWatcher{
watcher: w,
tree: t,
handlers: make(map[string]WatchHandler),
dirHandlers: make(map[string]WatchHandler),
debounce: make(map[string]*time.Timer),
log: log,
}, nil
}
// Watch registers a handler for a specific filesystem path and adds
// the inotify watch.
func (fw *FSWatcher) Watch(path string, handler WatchHandler) error {
fw.mu.Lock()
fw.handlers[path] = handler
fw.mu.Unlock()
return fw.watcher.Add(path)
}
// WatchGlob expands a glob pattern and registers a handler for each
// matching path. Returns the number of paths matched.
func (fw *FSWatcher) WatchGlob(pattern string, handler WatchHandler) (int, error) {
matches, err := filepath.Glob(pattern)
if err != nil {
return 0, fmt.Errorf("glob %s: %w", pattern, err)
}
for _, path := range matches {
if err := fw.Watch(path, handler); err != nil {
fw.log.Warn("fswatcher: watch failed, skipping", "path", path, "err", err)
}
}
return len(matches), nil
}
// WatchSymlink registers a handler for a symlink by watching its parent
// directory. fsnotify follows symlinks to the target inode, so replacing
// a symlink (ln -sf) would not trigger events on a direct watch. Watching
// the parent directory catches Create and Rename events for the symlink
// entry itself.
func (fw *FSWatcher) WatchSymlink(path string, handler WatchHandler) error {
dir := filepath.Dir(path)
fw.mu.Lock()
fw.handlers[path] = handler
fw.mu.Unlock()
return fw.watcher.Add(dir)
}
// WatchDir registers a handler for an entire directory. Any file
// create/write/remove event inside the directory triggers the handler
// with the directory path. The handler's ReadFunc receives the directory
// path (not the individual file), so it can rescan all contents.
func (fw *FSWatcher) WatchDir(dir string, handler WatchHandler) error {
fw.mu.Lock()
fw.dirHandlers[dir] = handler
fw.mu.Unlock()
return fw.watcher.Add(dir)
}
// InitialRead reads the current value of every watched file and
// populates the tree. Called once after all Watch() calls and glob
// expansion, before Run().
func (fw *FSWatcher) InitialRead() {
fw.mu.Lock()
defer fw.mu.Unlock()
for path, handler := range fw.handlers {
data, err := handler.ReadFunc(path)
if err != nil {
fw.log.Warn("fswatcher: initial read failed", "path", path, "err", err)
continue
}
if handler.UseMerge {
fw.tree.Merge(handler.TreeKey, data)
} else {
fw.tree.Set(handler.TreeKey, data)
}
fw.log.Debug("fswatcher: initial read", "path", path, "key", handler.TreeKey)
}
for dir, handler := range fw.dirHandlers {
data, err := handler.ReadFunc(dir)
if err != nil {
fw.log.Warn("fswatcher: initial read failed", "path", dir, "err", err)
continue
}
if handler.UseMerge {
fw.tree.Merge(handler.TreeKey, data)
} else {
fw.tree.Set(handler.TreeKey, data)
}
fw.log.Debug("fswatcher: initial read", "path", dir, "key", handler.TreeKey)
}
}
// Run processes inotify events until ctx is cancelled.
func (fw *FSWatcher) Run(ctx context.Context) error {
defer fw.watcher.Close()
for {
select {
case <-ctx.Done():
return ctx.Err()
case event, ok := <-fw.watcher.Events:
if !ok {
return fmt.Errorf("watcher closed")
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
fw.handleEvent(event.Name)
}
if event.Has(fsnotify.Remove) {
fw.handleRemove(event.Name)
}
case err, ok := <-fw.watcher.Errors:
if !ok {
return fmt.Errorf("watcher error channel closed")
}
fw.log.Warn("fsnotify error", "err", err)
}
}
}
// Close shuts down the inotify watcher and cancels pending timers.
func (fw *FSWatcher) Close() {
fw.mu.Lock()
defer fw.mu.Unlock()
for _, timer := range fw.debounce {
timer.Stop()
}
fw.watcher.Close()
}
func (fw *FSWatcher) handleEvent(path string) {
fw.mu.Lock()
handler, ok := fw.handlers[path]
handlerPath := path
if !ok {
dir := filepath.Dir(path)
handler, ok = fw.dirHandlers[dir]
handlerPath = dir
if !ok {
fw.mu.Unlock()
return
}
}
if handler.Debounce > 0 {
if timer, exists := fw.debounce[handlerPath]; exists {
timer.Reset(handler.Debounce)
fw.mu.Unlock()
return
}
fw.debounce[handlerPath] = time.AfterFunc(handler.Debounce, func() {
fw.fireHandler(handlerPath, handler)
})
fw.mu.Unlock()
return
}
fw.mu.Unlock()
fw.fireHandler(handlerPath, handler)
}
func (fw *FSWatcher) handleRemove(path string) {
fw.mu.Lock()
handler, ok := fw.handlers[path]
if !ok {
dir := filepath.Dir(path)
handler, ok = fw.dirHandlers[dir]
if ok {
fw.mu.Unlock()
fw.fireHandler(dir, handler)
return
}
fw.mu.Unlock()
return
}
fw.mu.Unlock()
if handler.UseMerge {
fw.fireHandler(path, handler)
} else {
fw.tree.Delete(handler.TreeKey)
fw.log.Debug("fswatcher: removed", "path", path, "key", handler.TreeKey)
}
if err := fw.watcher.Add(path); err != nil {
fw.mu.Lock()
delete(fw.handlers, path)
if timer, exists := fw.debounce[path]; exists {
timer.Stop()
delete(fw.debounce, path)
}
fw.mu.Unlock()
fw.log.Debug("fswatcher: file gone, handler removed", "path", path)
}
}
func (fw *FSWatcher) fireHandler(path string, handler WatchHandler) {
data, err := handler.ReadFunc(path)
if err != nil {
fw.log.Warn("fswatcher: read failed", "path", path, "err", err)
return
}
if handler.UseMerge {
fw.tree.Merge(handler.TreeKey, data)
} else {
fw.tree.Set(handler.TreeKey, data)
}
fw.log.Debug("fswatcher: updated", "path", path, "key", handler.TreeKey)
}
@@ -0,0 +1,613 @@
package fswatcher
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
func newTestFSWatcher(t *testing.T) (*FSWatcher, *tree.Tree) {
t.Helper()
tr := tree.New()
fw, err := New(tr, slog.Default())
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { fw.Close() })
return fw, tr
}
func TestNew(t *testing.T) {
tr := tree.New()
fw, err := New(tr, slog.Default())
if err != nil {
t.Fatalf("New: %v", err)
}
defer fw.Close()
if fw.tree != tr {
t.Error("tree not stored")
}
if fw.handlers == nil {
t.Error("handlers map nil")
}
if fw.debounce == nil {
t.Error("debounce map nil")
}
}
func TestWatch(t *testing.T) {
fw, _ := newTestFSWatcher(t)
tmp := t.TempDir()
path := filepath.Join(tmp, "test.txt")
if err := os.WriteFile(path, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
handler := WatchHandler{
TreeKey: "test/key",
ReadFunc: func(p string) (json.RawMessage, error) { return json.RawMessage(`"ok"`), nil },
}
if err := fw.Watch(path, handler); err != nil {
t.Fatalf("Watch: %v", err)
}
fw.mu.Lock()
_, ok := fw.handlers[path]
fw.mu.Unlock()
if !ok {
t.Error("handler not registered")
}
}
func TestInitialRead(t *testing.T) {
fw, tr := newTestFSWatcher(t)
tmp := t.TempDir()
p1 := filepath.Join(tmp, "a.txt")
p2 := filepath.Join(tmp, "b.txt")
os.WriteFile(p1, []byte("1"), 0644)
os.WriteFile(p2, []byte("2"), 0644)
fw.Watch(p1, WatchHandler{
TreeKey: "key/a",
ReadFunc: func(path string) (json.RawMessage, error) {
return json.RawMessage(`"value-a"`), nil
},
})
fw.Watch(p2, WatchHandler{
TreeKey: "key/b",
ReadFunc: func(path string) (json.RawMessage, error) {
return json.RawMessage(`"value-b"`), nil
},
})
fw.InitialRead()
if got := tr.Get("key/a"); string(got) != `"value-a"` {
t.Errorf("key/a = %s, want %q", got, `"value-a"`)
}
if got := tr.Get("key/b"); string(got) != `"value-b"` {
t.Errorf("key/b = %s, want %q", got, `"value-b"`)
}
}
func TestInitialReadError(t *testing.T) {
fw, tr := newTestFSWatcher(t)
tmp := t.TempDir()
p := filepath.Join(tmp, "fail.txt")
os.WriteFile(p, []byte("x"), 0644)
fw.Watch(p, WatchHandler{
TreeKey: "key/fail",
ReadFunc: func(path string) (json.RawMessage, error) {
return nil, fmt.Errorf("read error")
},
})
fw.InitialRead()
if got := tr.Get("key/fail"); got != nil {
t.Errorf("expected nil for failed read, got %s", got)
}
}
func TestWatchGlob(t *testing.T) {
fw, _ := newTestFSWatcher(t)
tmp := t.TempDir()
for _, name := range []string{"x1.conf", "x2.conf", "x3.conf"} {
os.WriteFile(filepath.Join(tmp, name), []byte("data"), 0644)
}
os.WriteFile(filepath.Join(tmp, "y.txt"), []byte("data"), 0644)
handler := WatchHandler{
TreeKey: "glob/test",
ReadFunc: func(p string) (json.RawMessage, error) { return json.RawMessage(`"g"`), nil },
}
n, err := fw.WatchGlob(filepath.Join(tmp, "x*.conf"), handler)
if err != nil {
t.Fatalf("WatchGlob: %v", err)
}
if n != 3 {
t.Errorf("WatchGlob matched %d, want 3", n)
}
fw.mu.Lock()
count := len(fw.handlers)
fw.mu.Unlock()
if count != 3 {
t.Errorf("handlers count = %d, want 3", count)
}
}
func TestRunWriteEvent(t *testing.T) {
fw, tr := newTestFSWatcher(t)
tmp := t.TempDir()
path := filepath.Join(tmp, "watched.txt")
os.WriteFile(path, []byte("initial"), 0644)
callCount := 0
fw.Watch(path, WatchHandler{
TreeKey: "run/test",
ReadFunc: func(p string) (json.RawMessage, error) {
callCount++
return json.RawMessage(`"updated"`), nil
},
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() { errCh <- fw.Run(ctx) }()
time.Sleep(50 * time.Millisecond)
os.WriteFile(path, []byte("changed"), 0644)
deadline := time.After(2 * time.Second)
for {
if got := tr.Get("run/test"); string(got) == `"updated"` {
break
}
select {
case <-deadline:
t.Fatal("timed out waiting for tree update after write event")
default:
time.Sleep(10 * time.Millisecond)
}
}
cancel()
err := <-errCh
if err != nil && err != context.Canceled {
t.Errorf("Run returned unexpected error: %v", err)
}
}
func TestFireHandler(t *testing.T) {
fw, tr := newTestFSWatcher(t)
handler := WatchHandler{
TreeKey: "fire/test",
ReadFunc: func(path string) (json.RawMessage, error) {
return json.RawMessage(`{"fired":true}`), nil
},
}
fw.fireHandler("/fake/path", handler)
if got := tr.Get("fire/test"); string(got) != `{"fired":true}` {
t.Errorf("tree value = %s, want %s", got, `{"fired":true}`)
}
}
func TestFireHandlerReadError(t *testing.T) {
fw, tr := newTestFSWatcher(t)
handler := WatchHandler{
TreeKey: "fire/err",
ReadFunc: func(path string) (json.RawMessage, error) {
return nil, fmt.Errorf("broken")
},
}
fw.fireHandler("/fake/path", handler)
if got := tr.Get("fire/err"); got != nil {
t.Errorf("expected nil for errored handler, got %s", got)
}
}
func TestDebounce(t *testing.T) {
fw, tr := newTestFSWatcher(t)
tmp := t.TempDir()
path := filepath.Join(tmp, "debounce.txt")
os.WriteFile(path, []byte("init"), 0644)
callCount := 0
fw.Watch(path, WatchHandler{
TreeKey: "debounce/test",
Debounce: 100 * time.Millisecond,
ReadFunc: func(p string) (json.RawMessage, error) {
callCount++
return json.RawMessage(fmt.Sprintf(`"call-%d"`, callCount)), nil
},
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go fw.Run(ctx)
time.Sleep(50 * time.Millisecond)
for i := 0; i < 5; i++ {
os.WriteFile(path, []byte(fmt.Sprintf("data-%d", i)), 0644)
time.Sleep(10 * time.Millisecond)
}
time.Sleep(300 * time.Millisecond)
got := tr.Get("debounce/test")
if got == nil {
t.Fatal("tree not updated after debounced writes")
}
if callCount > 3 {
t.Errorf("expected debounce to coalesce writes, but handler called %d times", callCount)
}
cancel()
}
func TestRunContextCancellation(t *testing.T) {
fw, _ := newTestFSWatcher(t)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() { errCh <- fw.Run(ctx) }()
time.Sleep(20 * time.Millisecond)
cancel()
err := <-errCh
if err != context.Canceled {
t.Errorf("Run error = %v, want context.Canceled", err)
}
}
func TestClose(t *testing.T) {
tr := tree.New()
fw, err := New(tr, slog.Default())
if err != nil {
t.Fatal(err)
}
tmp := t.TempDir()
path := filepath.Join(tmp, "close.txt")
os.WriteFile(path, []byte("x"), 0644)
fw.Watch(path, WatchHandler{
TreeKey: "close/test",
Debounce: time.Second,
ReadFunc: func(p string) (json.RawMessage, error) { return json.RawMessage(`"x"`), nil },
})
fw.handleEvent(path)
fw.mu.Lock()
timerCount := len(fw.debounce)
fw.mu.Unlock()
if timerCount != 1 {
t.Errorf("expected 1 debounce timer, got %d", timerCount)
}
fw.Close()
}
func TestHandleRemoveMergeHandler(t *testing.T) {
fw, tr := newTestFSWatcher(t)
tmp := t.TempDir()
path := filepath.Join(tmp, "forwarding")
os.WriteFile(path, []byte("1"), 0644)
fw.Watch(path, WatchHandler{
TreeKey: "routing",
ReadFunc: func(_ string) (json.RawMessage, error) {
return json.RawMessage(`{"interfaces":{"interface":["eth0"]}}`), nil
},
UseMerge: true,
})
fw.InitialRead()
got := tr.Get("routing")
if got == nil {
t.Fatal("tree not populated after InitialRead")
}
os.Remove(path)
fw.handleRemove(path)
got = tr.Get("routing")
if got == nil {
t.Fatal("tree entry should still exist after merge-remove")
}
if string(got) != `{"interfaces":{"interface":["eth0"]}}` {
t.Errorf("got %s, want updated merge data", got)
}
fw.mu.Lock()
_, handlerExists := fw.handlers[path]
fw.mu.Unlock()
if handlerExists {
t.Error("handler should be cleaned up after permanent removal")
}
}
func TestHandleRemovePlainHandler(t *testing.T) {
fw, tr := newTestFSWatcher(t)
tmp := t.TempDir()
path := filepath.Join(tmp, "value.txt")
os.WriteFile(path, []byte("data"), 0644)
fw.Watch(path, WatchHandler{
TreeKey: "plain/key",
ReadFunc: func(p string) (json.RawMessage, error) {
return json.RawMessage(`"hello"`), nil
},
})
fw.InitialRead()
if got := tr.Get("plain/key"); string(got) != `"hello"` {
t.Fatalf("initial = %s, want %q", got, `"hello"`)
}
os.Remove(path)
fw.handleRemove(path)
if got := tr.Get("plain/key"); got != nil {
t.Errorf("tree entry should be deleted after remove, got %s", got)
}
fw.mu.Lock()
_, handlerExists := fw.handlers[path]
fw.mu.Unlock()
if handlerExists {
t.Error("handler should be cleaned up after permanent removal")
}
}
func TestHandleRemoveUnknownPath(t *testing.T) {
fw, _ := newTestFSWatcher(t)
fw.handleRemove("/nonexistent/path")
}
func TestHandleRemoveRewatchSuccess(t *testing.T) {
fw, tr := newTestFSWatcher(t)
tmp := t.TempDir()
path := filepath.Join(tmp, "ephemeral.txt")
os.WriteFile(path, []byte("1"), 0644)
calls := 0
fw.Watch(path, WatchHandler{
TreeKey: "ephem",
ReadFunc: func(_ string) (json.RawMessage, error) {
calls++
return json.RawMessage(fmt.Sprintf(`"v%d"`, calls)), nil
},
UseMerge: true,
})
fw.InitialRead()
fw.handleRemove(path)
fw.mu.Lock()
_, handlerExists := fw.handlers[path]
fw.mu.Unlock()
if !handlerExists {
t.Error("handler should still exist when file still exists (rewatch succeeds)")
}
got := tr.Get("ephem")
if string(got) != `"v2"` {
t.Errorf("got %s, want %q (handler should have been called again)", got, `"v2"`)
}
}
func TestWatchSymlink(t *testing.T) {
fw, _ := newTestFSWatcher(t)
tmp := t.TempDir()
targetA := filepath.Join(tmp, "target-a")
targetB := filepath.Join(tmp, "target-b")
link := filepath.Join(tmp, "link")
os.WriteFile(targetA, []byte("a"), 0644)
os.WriteFile(targetB, []byte("b"), 0644)
os.Symlink(targetA, link)
handler := WatchHandler{
TreeKey: "sym/test",
ReadFunc: func(p string) (json.RawMessage, error) { return json.RawMessage(`"sym"`), nil },
}
if err := fw.WatchSymlink(link, handler); err != nil {
t.Fatalf("WatchSymlink: %v", err)
}
fw.mu.Lock()
_, ok := fw.handlers[link]
fw.mu.Unlock()
if !ok {
t.Error("handler not registered under symlink path")
}
}
func TestWatchSymlinkReplace(t *testing.T) {
fw, tr := newTestFSWatcher(t)
tmp := t.TempDir()
targetA := filepath.Join(tmp, "zone-a")
targetB := filepath.Join(tmp, "zone-b")
link := filepath.Join(tmp, "current")
os.WriteFile(targetA, []byte("a"), 0644)
os.WriteFile(targetB, []byte("b"), 0644)
os.Symlink(targetA, link)
calls := 0
fw.WatchSymlink(link, WatchHandler{
TreeKey: "sym/replace",
ReadFunc: func(p string) (json.RawMessage, error) {
calls++
target, _ := os.Readlink(p)
return json.RawMessage(fmt.Sprintf(`"target-%d-%s"`, calls, filepath.Base(target))), nil
},
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go fw.Run(ctx)
time.Sleep(50 * time.Millisecond)
os.Remove(link)
os.Symlink(targetB, link)
deadline := time.After(2 * time.Second)
for {
got := tr.Get("sym/replace")
if got != nil && strings.Contains(string(got), "zone-b") {
break
}
select {
case <-deadline:
t.Fatalf("timed out waiting for symlink replace event; tree = %s", tr.Get("sym/replace"))
default:
time.Sleep(10 * time.Millisecond)
}
}
cancel()
}
func TestWatchDir(t *testing.T) {
fw, tr := newTestFSWatcher(t)
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "a.keys"), []byte("key-a"), 0644)
fw.WatchDir(tmp, WatchHandler{
TreeKey: "dir/test",
ReadFunc: func(dir string) (json.RawMessage, error) {
entries, _ := os.ReadDir(dir)
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
return json.Marshal(map[string]interface{}{"files": names})
},
Debounce: 50 * time.Millisecond,
UseMerge: true,
})
fw.InitialRead()
got := tr.Get("dir/test")
if got == nil {
t.Fatal("tree not populated after InitialRead for dir handler")
}
if !strings.Contains(string(got), "a.keys") {
t.Fatalf("initial read missing a.keys: %s", got)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go fw.Run(ctx)
time.Sleep(50 * time.Millisecond)
os.WriteFile(filepath.Join(tmp, "b.keys"), []byte("key-b"), 0644)
deadline := time.After(2 * time.Second)
for {
got = tr.Get("dir/test")
if got != nil && strings.Contains(string(got), "b.keys") {
break
}
select {
case <-deadline:
t.Fatalf("timed out waiting for dir event; tree = %s", tr.Get("dir/test"))
default:
time.Sleep(10 * time.Millisecond)
}
}
cancel()
}
func TestWatchDirRemoveFile(t *testing.T) {
fw, tr := newTestFSWatcher(t)
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "x.keys"), []byte("data"), 0644)
os.WriteFile(filepath.Join(tmp, "y.keys"), []byte("data"), 0644)
fw.WatchDir(tmp, WatchHandler{
TreeKey: "dir/rm",
ReadFunc: func(dir string) (json.RawMessage, error) {
entries, _ := os.ReadDir(dir)
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
return json.Marshal(map[string]interface{}{"files": names})
},
UseMerge: true,
})
fw.InitialRead()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go fw.Run(ctx)
time.Sleep(50 * time.Millisecond)
os.Remove(filepath.Join(tmp, "x.keys"))
deadline := time.After(2 * time.Second)
for {
got := tr.Get("dir/rm")
if got != nil && !strings.Contains(string(got), "x.keys") && strings.Contains(string(got), "y.keys") {
break
}
select {
case <-deadline:
t.Fatalf("timed out waiting for dir remove event; tree = %s", tr.Get("dir/rm"))
default:
time.Sleep(10 * time.Millisecond)
}
}
cancel()
}
+838
View File
@@ -0,0 +1,838 @@
// Package iface transforms raw `ip -json` data into YANG-shaped
// ietf-interfaces JSON.
package iface
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// FileChecker abstracts filesystem probes needed during interface transformation.
type FileChecker interface {
Exists(path string) bool
ReadFile(path string) (string, error)
}
// Transform converts raw `ip -json` link/address/statistics arrays into
// `{"interface":[...]}`. The caller (NLMonitor) stores this at tree key
// "ietf-interfaces:interfaces"; the IPC server adds the module-qualified
// wrapper when responding to clients.
func Transform(linkData, addrData, statsData json.RawMessage, fc FileChecker) json.RawMessage {
links := dedup(decodeObjects(linkData))
addrs := decodeObjects(addrData)
stats := decodeObjects(statsData)
addrByName := make(map[string]map[string]any, len(addrs))
for _, addr := range addrs {
ifname := getString(addr, "ifname")
if ifname == "" {
continue
}
addrByName[ifname] = addr
}
statsByName := make(map[string]map[string]any, len(stats))
for _, st := range stats {
ifname := getString(st, "ifname")
if ifname == "" {
continue
}
statsByName[ifname] = st
}
interfaces := make([]map[string]any, 0, len(links))
for _, iplink := range links {
if skipInterface(iplink) {
continue
}
ifname := getString(iplink, "ifname")
ipaddr, ok := addrByName[ifname]
if !ok {
ipaddr = map[string]any{}
}
if st, ok := statsByName[ifname]; ok {
if stat64, ok := st["stats64"]; ok {
iplink["stats64"] = stat64
}
}
iface := interfaceCommon(iplink, ipaddr, fc)
yangType := getString(iface, "type")
switch yangType {
case "infix-if-type:vlan":
if v := vlanAugment(iplink); len(v) > 0 {
iface["infix-interfaces:vlan"] = v
}
case "infix-if-type:veth":
if v := vethAugment(iplink); len(v) > 0 {
iface["infix-interfaces:veth"] = v
}
case "infix-if-type:gre", "infix-if-type:gretap":
if v := greAugment(iplink); len(v) > 0 {
iface["infix-interfaces:gre"] = v
}
case "infix-if-type:vxlan":
if v := vxlanAugment(iplink); len(v) > 0 {
iface["infix-interfaces:vxlan"] = v
}
case "infix-if-type:lag":
if v := lagAugment(iplink); len(v) > 0 {
iface["infix-interfaces:lag"] = v
}
}
switch iplink2yangLower(iplink) {
case "infix-interfaces:bridge-port":
if lower := bridgePortLower(iplink); len(lower) > 0 {
iface["infix-interfaces:bridge-port"] = lower
}
case "infix-interfaces:lag-port":
if lower := lagPortLower(iplink); len(lower) > 0 {
iface["infix-interfaces:lag-port"] = lower
}
}
interfaces = append(interfaces, iface)
}
out := map[string]any{
"interface": interfaces,
}
raw, err := json.Marshal(out)
if err != nil {
return json.RawMessage(`{"interface":[]}`)
}
return raw
}
func decodeObjects(raw json.RawMessage) []map[string]any {
if len(raw) == 0 {
return nil
}
var entries []any
if err := json.Unmarshal(raw, &entries); err != nil {
return nil
}
out := make([]map[string]any, 0, len(entries))
for _, entry := range entries {
obj, ok := asMap(entry)
if !ok {
continue
}
out = append(out, obj)
}
return out
}
func skipInterface(iplink map[string]any) bool {
if getString(iplink, "group") == "internal" {
return true
}
switch getString(iplink, "link_type") {
case "can", "vcan":
return true
default:
return false
}
}
// dedup removes duplicate link entries that share the same ifindex.
// When an interface is renamed (e.g. eth0 → e1), ip -json may report
// both the old and new names with the same ifindex. We keep the entry
// whose operstate is "UP", or the last one seen if neither is up.
func dedup(links []map[string]any) []map[string]any {
seen := make(map[int]int, len(links))
out := make([]map[string]any, 0, len(links))
for _, link := range links {
idx := getIntOrZero(link, "ifindex")
if idx == 0 {
out = append(out, link)
continue
}
if prev, ok := seen[idx]; ok {
if getString(link, "operstate") == "UP" && getString(out[prev], "operstate") != "UP" {
out[prev] = link
}
} else {
seen[idx] = len(out)
out = append(out, link)
}
}
return out
}
func interfaceCommon(iplink, ipaddr map[string]any, fc FileChecker) map[string]any {
flags := getStrings(iplink, "flags")
iface := map[string]any{
"type": iplink2yangType(iplink, fc),
"name": getString(iplink, "ifname"),
"if-index": getIntOrZero(iplink, "ifindex"),
"admin-status": boolToStatus(contains(flags, "UP"), "up", "down"),
"oper-status": iplink2yangOperstate(getString(iplink, "operstate")),
}
if _, ok := iplink["ifalias"]; ok {
iface["description"] = getString(iplink, "ifalias")
}
if !contains(flags, "POINTOPOINT") {
if address, ok := iplink["address"]; ok {
iface["phys-address"] = fmt.Sprintf("%v", address)
}
}
if stats := statistics(iplink); len(stats) > 0 {
iface["statistics"] = stats
}
if ipv4 := ipv4Data(ipaddr); len(ipv4) > 0 {
iface["ietf-ip:ipv4"] = ipv4
}
if ipv6 := ipv6Data(ipaddr, fc); len(ipv6) > 0 {
iface["ietf-ip:ipv6"] = ipv6
}
return iface
}
func iplink2yangType(iplink map[string]any, fc FileChecker) string {
ifname := getString(iplink, "ifname")
switch getString(iplink, "link_type") {
case "loopback":
return "infix-if-type:loopback"
case "gre", "gre6":
return "infix-if-type:gre"
case "ether":
if fc != nil {
if fc.Exists(fmt.Sprintf("/sys/class/net/%s/wireless/", ifname)) {
return "infix-if-type:wifi"
}
}
case "none":
default:
return "infix-if-type:other"
}
linkinfo, _ := asMap(iplink["linkinfo"])
switch getString(linkinfo, "info_kind") {
case "bond":
return "infix-if-type:lag"
case "bridge":
return "infix-if-type:bridge"
case "dummy":
return "infix-if-type:dummy"
case "gretap", "ip6gretap":
return "infix-if-type:gretap"
case "vxlan":
return "infix-if-type:vxlan"
case "veth":
return "infix-if-type:veth"
case "vlan":
return "infix-if-type:vlan"
case "wireguard":
return "infix-if-type:wireguard"
default:
return "infix-if-type:ethernet"
}
}
func iplink2yangLower(iplink map[string]any) string {
linkinfo, _ := asMap(iplink["linkinfo"])
switch getString(linkinfo, "info_slave_kind") {
case "bridge":
return "infix-interfaces:bridge-port"
case "bond":
return "infix-interfaces:lag-port"
default:
return ""
}
}
func iplink2yangOperstate(oper string) string {
switch oper {
case "DOWN":
return "down"
case "UP":
return "up"
case "DORMANT":
return "dormant"
case "TESTING":
return "testing"
case "LOWERLAYERDOWN":
return "lower-layer-down"
case "NOTPRESENT":
return "not-present"
default:
return "unknown"
}
}
func statistics(iplink map[string]any) map[string]any {
out := map[string]any{}
stats64, _ := asMap(iplink["stats64"])
rx, _ := asMap(stats64["rx"])
tx, _ := asMap(stats64["tx"])
if octets, ok := rx["bytes"]; ok && isTruthy(octets) {
out["in-octets"] = toCounterString(octets)
}
if octets, ok := tx["bytes"]; ok && isTruthy(octets) {
out["out-octets"] = toCounterString(octets)
}
return out
}
func ipv4Data(ipaddr map[string]any) map[string]any {
if len(ipaddr) == 0 {
return nil
}
out := map[string]any{}
if mtu, ok := getInt(ipaddr, "mtu"); ok && mtu != 0 && getString(ipaddr, "ifname") != "lo" {
out["mtu"] = mtu
}
if addr := addresses(ipaddr, "inet"); len(addr) > 0 {
out["address"] = addr
}
return out
}
func ipv6Data(ipaddr map[string]any, fc FileChecker) map[string]any {
if len(ipaddr) == 0 {
return nil
}
out := map[string]any{}
ifname := getString(ipaddr, "ifname")
if ifname != "" && fc != nil {
path := fmt.Sprintf("/proc/sys/net/ipv6/conf/%s/mtu", ifname)
if raw, err := fc.ReadFile(path); err == nil {
trimmed := strings.TrimSpace(raw)
if mtu, err := strconv.Atoi(trimmed); err == nil {
out["mtu"] = mtu
}
}
}
if addr := addresses(ipaddr, "inet6"); len(addr) > 0 {
out["address"] = addr
}
return out
}
func addresses(ipaddr map[string]any, family string) []map[string]any {
addrInfo, ok := ipaddr["addr_info"]
if !ok {
return nil
}
arr, ok := asArray(addrInfo)
if !ok {
return nil
}
out := make([]map[string]any, 0, len(arr))
for _, entry := range arr {
inet, ok := asMap(entry)
if !ok {
continue
}
if getString(inet, "family") != family {
continue
}
address := map[string]any{
"ip": inet["local"],
"prefix-length": getIntOrZero(inet, "prefixlen"),
"origin": inet2yangOrigin(inet),
}
out = append(out, address)
}
return out
}
func inet2yangOrigin(inet map[string]any) string {
proto := getString(inet, "protocol")
if proto == "kernel_ll" || proto == "kernel_ra" {
if _, ok := inet["stable-privacy"]; ok {
return "random"
}
}
switch proto {
case "kernel_ll", "kernel_ra":
return "link-layer"
case "static":
return "static"
case "dhcp":
return "dhcp"
case "random":
return "random"
default:
return "other"
}
}
func vlanAugment(iplink map[string]any) map[string]any {
info := infoData(iplink)
if len(info) == 0 {
return nil
}
vlan := map[string]any{
"tag-type": proto2yang(getString(info, "protocol")),
"id": getIntOrZero(info, "id"),
}
if lower := getString(iplink, "link"); lower != "" {
vlan["lower-layer-if"] = lower
}
return vlan
}
func vethAugment(iplink map[string]any) map[string]any {
peer := getString(iplink, "link")
if peer == "" {
return nil
}
return map[string]any{"peer": peer}
}
func greAugment(iplink map[string]any) map[string]any {
info := infoData(iplink)
if len(info) == 0 {
return nil
}
return map[string]any{
"local": firstAny(info["local"], info["local6"]),
"remote": firstAny(info["remote"], info["remote6"]),
}
}
func vxlanAugment(iplink map[string]any) map[string]any {
vxlan := greAugment(iplink)
if len(vxlan) == 0 {
return nil
}
info := infoData(iplink)
if vni, ok := info["id"]; ok {
vxlan["vni"] = vni
}
return vxlan
}
func lagAugment(iplink map[string]any) map[string]any {
info := infoData(iplink)
if len(info) == 0 {
return nil
}
mode := lagMode(getString(info, "mode"))
bond := map[string]any{
"mode": mode,
"link-monitor": map[string]any{
"debounce": map[string]any{
"up": getIntOrZero(info, "updelay"),
"down": getIntOrZero(info, "downdelay"),
},
},
}
if mode == "lacp" {
lacp := map[string]any{
"mode": boolToStatus(getString(info, "ad_lacp_active") == "on", "active", "passive"),
"rate": getString(info, "ad_lacp_rate"),
"hash": lagHash(getString(info, "xmit_hash_policy")),
}
adInfo, ok := asMap(info["ad_info"])
if ok {
if v, ok := adInfo["aggregator"]; ok {
lacp["aggregator-id"] = v
}
if v, ok := adInfo["actor_key"]; ok {
lacp["actor-key"] = v
}
if v, ok := adInfo["partner_key"]; ok {
lacp["partner-key"] = v
}
if v, ok := adInfo["partner_mac"]; ok {
lacp["partner-mac"] = v
}
}
if v, ok := info["ad_actor_sys_prio"]; ok {
lacp["system-priority"] = v
}
bond["lacp"] = lacp
} else {
bond["static"] = map[string]any{
"mode": getString(info, "mode"),
"hash": getString(info, "xmit_hash_policy"),
}
}
return bond
}
func bridgePortLower(iplink map[string]any) map[string]any {
master := getString(iplink, "master")
if master == "" {
return nil
}
linkinfo, _ := asMap(iplink["linkinfo"])
info, _ := asMap(linkinfo["info_slave_data"])
if len(info) == 0 {
return nil
}
return map[string]any{
"bridge": master,
"flood": map[string]any{
"broadcast": getBool(info, "bcast_flood"),
"unicast": getBool(info, "flood"),
"multicast": getBool(info, "mcast_flood"),
},
"multicast": map[string]any{
"fast-leave": getBool(info, "fastleave"),
"router": bridgeRouterMode(getIntOrZero(info, "multicast_router")),
},
"stp": map[string]any{},
}
}
func lagPortLower(iplink map[string]any) map[string]any {
master := getString(iplink, "master")
if master == "" {
return nil
}
port := map[string]any{"lag": master}
linkinfo, _ := asMap(iplink["linkinfo"])
info, _ := asMap(linkinfo["info_slave_data"])
if len(info) == 0 {
port["state"] = "backup"
port["link-failures"] = 0
return port
}
port["state"] = strings.ToLower(getString(info, "state"))
port["link-failures"] = getIntOrZero(info, "link_failure_count")
if _, ok := info["ad_aggregator_id"]; ok {
port["lacp"] = map[string]any{
"aggregator-id": info["ad_aggregator_id"],
"actor-state": getString(info, "ad_actor_oper_port_state_str"),
"partner-state": getString(info, "ad_partner_oper_port_state_str"),
}
}
return port
}
func infoData(iplink map[string]any) map[string]any {
linkinfo, ok := asMap(iplink["linkinfo"])
if !ok {
return nil
}
data, ok := asMap(linkinfo["info_data"])
if !ok {
return nil
}
return data
}
func proto2yang(proto string) string {
switch proto {
case "802.1Q":
return "ieee802-dot1q-types:c-vlan"
case "802.1ad":
return "ieee802-dot1q-types:s-vlan"
default:
return "other"
}
}
func lagMode(mode string) string {
switch mode {
case "802.3ad":
return "lacp"
case "balance-xor":
return "static"
default:
return "static"
}
}
func lagHash(hash string) string {
switch hash {
case "layer2":
return "layer2"
case "layer3+4":
return "layer3-4"
case "layer2+3":
return "layer2-3"
case "encap2+3":
return "encap2-3"
case "encap3+4":
return "encap3-4"
case "vlan+srcmac":
return "vlan-srcmac"
default:
return "layer2"
}
}
func bridgeRouterMode(v int) string {
switch v {
case 0:
return "off"
case 1:
return "auto"
case 2:
return "permanent"
default:
return "UNKNOWN"
}
}
func getString(obj map[string]any, key string) string {
v, ok := obj[key]
if !ok || v == nil {
return ""
}
s, ok := v.(string)
if ok {
return s
}
return fmt.Sprintf("%v", v)
}
func getInt(obj map[string]any, key string) (int, bool) {
v, ok := obj[key]
if !ok || v == nil {
return 0, false
}
switch n := v.(type) {
case int:
return n, true
case int8:
return int(n), true
case int16:
return int(n), true
case int32:
return int(n), true
case int64:
return int(n), true
case uint:
return int(n), true
case uint8:
return int(n), true
case uint16:
return int(n), true
case uint32:
return int(n), true
case uint64:
return int(n), true
case float64:
return int(n), true
case json.Number:
i, err := n.Int64()
if err != nil {
return 0, false
}
return int(i), true
case string:
i, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil {
return 0, false
}
return i, true
default:
return 0, false
}
}
func getIntOrZero(obj map[string]any, key string) int {
v, ok := getInt(obj, key)
if !ok {
return 0
}
return v
}
func getBool(obj map[string]any, key string) bool {
v, ok := obj[key]
if !ok || v == nil {
return false
}
b, ok := v.(bool)
if ok {
return b
}
s := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
return s == "1" || s == "true" || s == "on" || s == "yes"
}
func getStrings(obj map[string]any, key string) []string {
v, ok := obj[key]
if !ok || v == nil {
return nil
}
if direct, ok := v.([]string); ok {
return direct
}
arr, ok := asArray(v)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, item := range arr {
out = append(out, fmt.Sprintf("%v", item))
}
return out
}
func asMap(v any) (map[string]any, bool) {
if v == nil {
return nil, false
}
m, ok := v.(map[string]any)
if ok {
return m, true
}
m2, ok := v.(map[string]interface{})
if ok {
return map[string]any(m2), true
}
return nil, false
}
func asArray(v any) ([]any, bool) {
if v == nil {
return nil, false
}
arr, ok := v.([]any)
if ok {
return arr, true
}
arr2, ok := v.([]interface{})
if ok {
return []any(arr2), true
}
return nil, false
}
func contains(values []string, needle string) bool {
for _, value := range values {
if value == needle {
return true
}
}
return false
}
func isTruthy(v any) bool {
if v == nil {
return false
}
s := strings.TrimSpace(fmt.Sprintf("%v", v))
return s != "" && s != "0"
}
func toCounterString(v any) string {
switch n := v.(type) {
case int:
return strconv.FormatInt(int64(n), 10)
case int8:
return strconv.FormatInt(int64(n), 10)
case int16:
return strconv.FormatInt(int64(n), 10)
case int32:
return strconv.FormatInt(int64(n), 10)
case int64:
return strconv.FormatInt(n, 10)
case uint:
return strconv.FormatUint(uint64(n), 10)
case uint8:
return strconv.FormatUint(uint64(n), 10)
case uint16:
return strconv.FormatUint(uint64(n), 10)
case uint32:
return strconv.FormatUint(uint64(n), 10)
case uint64:
return strconv.FormatUint(n, 10)
case float64:
return strconv.FormatInt(int64(n), 10)
case json.Number:
return n.String()
case string:
return strings.TrimSpace(n)
default:
return fmt.Sprintf("%v", v)
}
}
func firstAny(a, b any) any {
if a != nil {
s := strings.TrimSpace(fmt.Sprintf("%v", a))
if s != "" {
return a
}
}
return b
}
func boolToStatus(cond bool, yes, no string) string {
if cond {
return yes
}
return no
}
+841
View File
@@ -0,0 +1,841 @@
package iface
import (
"encoding/json"
"errors"
"testing"
)
type mockFileChecker struct {
exists map[string]bool
files map[string]string
readErr map[string]error
}
func (m *mockFileChecker) Exists(path string) bool {
if m == nil || m.exists == nil {
return false
}
return m.exists[path]
}
func (m *mockFileChecker) ReadFile(path string) (string, error) {
if m == nil {
return "", errors.New("nil file checker")
}
if err, ok := m.readErr[path]; ok {
return "", err
}
if v, ok := m.files[path]; ok {
return v, nil
}
return "", errors.New("not found")
}
func mustRaw(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
func mustInterfaces(t *testing.T, raw json.RawMessage) []map[string]any {
t.Helper()
var root map[string]any
if err := json.Unmarshal(raw, &root); err != nil {
t.Fatalf("unmarshal transform output: %v", err)
}
arr, ok := root["interface"].([]any)
if !ok {
t.Fatalf("missing interface list: %#v", root)
}
out := make([]map[string]any, 0, len(arr))
for _, v := range arr {
m, ok := v.(map[string]any)
if !ok {
t.Fatalf("interface entry not object: %T", v)
}
out = append(out, m)
}
return out
}
func mustIfaceByName(t *testing.T, ifaces []map[string]any, name string) map[string]any {
t.Helper()
for _, iface := range ifaces {
if iface["name"] == name {
return iface
}
}
t.Fatalf("interface %q not found", name)
return nil
}
func TestTransformEmptyInputs(t *testing.T) {
tests := []struct {
name string
linkData json.RawMessage
addrData json.RawMessage
stats json.RawMessage
}{
{name: "nil raw messages"},
{
name: "empty arrays",
linkData: mustRaw(t, []map[string]any{}),
addrData: mustRaw(t, []map[string]any{}),
stats: mustRaw(t, []map[string]any{}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ifaces := mustInterfaces(t, Transform(tt.linkData, tt.addrData, tt.stats, nil))
if len(ifaces) != 0 {
t.Fatalf("expected empty interface list, got %d", len(ifaces))
}
})
}
}
func TestTransformSingleLoopback(t *testing.T) {
link := []map[string]any{{
"ifindex": 1,
"ifname": "lo",
"flags": []any{"LOOPBACK", "UP"},
"link_type": "loopback",
"operstate": "UNKNOWN",
"address": "00:00:00:00:00:00",
"statistics": map[string]any{},
}}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
if len(ifaces) != 1 {
t.Fatalf("expected 1 interface, got %d", len(ifaces))
}
lo := ifaces[0]
if lo["name"] != "lo" {
t.Fatalf("name = %v", lo["name"])
}
if lo["type"] != "infix-if-type:loopback" {
t.Fatalf("type = %v", lo["type"])
}
if lo["admin-status"] != "up" || lo["oper-status"] != "unknown" {
t.Fatalf("admin/oper mismatch: %v/%v", lo["admin-status"], lo["oper-status"])
}
}
func TestTransformSingleEthernetWithIPv4IPv6(t *testing.T) {
link := []map[string]any{{
"ifindex": 2,
"ifname": "eth0",
"flags": []any{"UP"},
"link_type": "ether",
"operstate": "UP",
"address": "52:54:00:12:34:56",
}}
addr := []map[string]any{{
"ifname": "eth0",
"mtu": 1500,
"addr_info": []map[string]any{
{"family": "inet", "local": "192.0.2.10", "prefixlen": 24, "protocol": "static"},
{"family": "inet6", "local": "2001:db8::10", "prefixlen": 64, "protocol": "kernel_ra"},
},
}}
fc := &mockFileChecker{files: map[string]string{"/proc/sys/net/ipv6/conf/eth0/mtu": "1400\n"}}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), mustRaw(t, addr), nil, fc))
eth0 := mustIfaceByName(t, ifaces, "eth0")
if eth0["type"] != "infix-if-type:ethernet" {
t.Fatalf("unexpected type: %v", eth0["type"])
}
ipv4, ok := eth0["ietf-ip:ipv4"].(map[string]any)
if !ok {
t.Fatalf("missing ipv4 container: %#v", eth0)
}
if ipv4["mtu"] != float64(1500) {
t.Fatalf("ipv4 mtu = %v", ipv4["mtu"])
}
v4addrs := ipv4["address"].([]any)
v4 := v4addrs[0].(map[string]any)
if v4["ip"] != "192.0.2.10" || v4["prefix-length"] != float64(24) || v4["origin"] != "static" {
t.Fatalf("unexpected ipv4 address entry: %#v", v4)
}
ipv6, ok := eth0["ietf-ip:ipv6"].(map[string]any)
if !ok {
t.Fatalf("missing ipv6 container: %#v", eth0)
}
if ipv6["mtu"] != float64(1400) {
t.Fatalf("ipv6 mtu = %v", ipv6["mtu"])
}
v6addrs := ipv6["address"].([]any)
v6 := v6addrs[0].(map[string]any)
if v6["ip"] != "2001:db8::10" || v6["prefix-length"] != float64(64) || v6["origin"] != "link-layer" {
t.Fatalf("unexpected ipv6 address entry: %#v", v6)
}
}
func TestTransformStatisticsCountersAsStrings(t *testing.T) {
link := []map[string]any{{
"ifindex": 3,
"ifname": "eth1",
"flags": []any{"UP"},
"link_type": "ether",
"operstate": "UP",
}}
stats := []map[string]any{{
"ifname": "eth1",
"stats64": map[string]any{
"rx": map[string]any{"bytes": uint64(1234567890)},
"tx": map[string]any{"bytes": uint64(9876543210)},
},
}}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, mustRaw(t, stats), nil))
eth1 := mustIfaceByName(t, ifaces, "eth1")
st, ok := eth1["statistics"].(map[string]any)
if !ok {
t.Fatalf("missing statistics: %#v", eth1)
}
if _, ok := st["in-octets"].(string); !ok {
t.Fatalf("in-octets must be string, got %T", st["in-octets"])
}
if _, ok := st["out-octets"].(string); !ok {
t.Fatalf("out-octets must be string, got %T", st["out-octets"])
}
}
func TestTransformVLANAugment(t *testing.T) {
link := []map[string]any{{
"ifindex": 10,
"ifname": "eth0.100",
"flags": []any{"UP"},
"link_type": "none",
"operstate": "UP",
"link": "eth0",
"linkinfo": map[string]any{
"info_kind": "vlan",
"info_data": map[string]any{"protocol": "802.1Q", "id": 100},
},
}}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
vlan := mustIfaceByName(t, ifaces, "eth0.100")
if vlan["type"] != "infix-if-type:vlan" {
t.Fatalf("type = %v", vlan["type"])
}
v, ok := vlan["infix-interfaces:vlan"].(map[string]any)
if !ok {
t.Fatalf("missing vlan augment: %#v", vlan)
}
if v["tag-type"] != "ieee802-dot1q-types:c-vlan" || v["id"] != float64(100) || v["lower-layer-if"] != "eth0" {
t.Fatalf("unexpected vlan augment: %#v", v)
}
}
func TestTransformVethAugment(t *testing.T) {
link := []map[string]any{{
"ifname": "veth0",
"ifindex": 11,
"flags": []any{"UP"},
"link_type": "none",
"operstate": "UP",
"link": "veth1",
"linkinfo": map[string]any{"info_kind": "veth"},
}}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
veth := mustIfaceByName(t, ifaces, "veth0")
v, ok := veth["infix-interfaces:veth"].(map[string]any)
if !ok || v["peer"] != "veth1" {
t.Fatalf("unexpected veth augment: %#v", veth)
}
}
func TestTransformGREAndVXLANAugments(t *testing.T) {
link := []map[string]any{
{
"ifname": "gre1",
"ifindex": 12,
"flags": []any{"UP"},
"link_type": "gre",
"operstate": "UP",
"linkinfo": map[string]any{
"info_data": map[string]any{"local": "192.0.2.1", "remote": "198.51.100.1"},
},
},
{
"ifname": "vxlan10",
"ifindex": 13,
"flags": []any{"UP"},
"link_type": "none",
"operstate": "UP",
"linkinfo": map[string]any{
"info_kind": "vxlan",
"info_data": map[string]any{"local": "10.0.0.1", "remote": "10.0.0.2", "id": 10},
},
},
}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
gre := mustIfaceByName(t, ifaces, "gre1")
if gre["type"] != "infix-if-type:gre" {
t.Fatalf("gre type = %v", gre["type"])
}
g, ok := gre["infix-interfaces:gre"].(map[string]any)
if !ok || g["local"] != "192.0.2.1" || g["remote"] != "198.51.100.1" {
t.Fatalf("unexpected gre augment: %#v", g)
}
vx := mustIfaceByName(t, ifaces, "vxlan10")
if vx["type"] != "infix-if-type:vxlan" {
t.Fatalf("vxlan type = %v", vx["type"])
}
v, ok := vx["infix-interfaces:vxlan"].(map[string]any)
if !ok || v["local"] != "10.0.0.1" || v["remote"] != "10.0.0.2" || v["vni"] != float64(10) {
t.Fatalf("unexpected vxlan augment: %#v", v)
}
}
func TestTransformLAGAugmentModes(t *testing.T) {
link := []map[string]any{
{
"ifname": "bond0",
"ifindex": 20,
"flags": []any{"UP"},
"link_type": "none",
"operstate": "UP",
"linkinfo": map[string]any{
"info_kind": "bond",
"info_data": map[string]any{
"mode": "802.3ad",
"updelay": 10,
"downdelay": 20,
"ad_lacp_active": "on",
"ad_lacp_rate": "fast",
"xmit_hash_policy": "layer3+4",
"ad_actor_sys_prio": 100,
"ad_info": map[string]any{
"aggregator": 7,
"actor_key": 1000,
"partner_key": 2000,
"partner_mac": "02:00:00:00:00:01",
},
},
},
},
{
"ifname": "bond1",
"ifindex": 21,
"flags": []any{"UP"},
"link_type": "none",
"operstate": "UP",
"linkinfo": map[string]any{
"info_kind": "bond",
"info_data": map[string]any{
"mode": "balance-xor",
"xmit_hash_policy": "layer2",
},
},
},
}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
bond0 := mustIfaceByName(t, ifaces, "bond0")
b0 := bond0["infix-interfaces:lag"].(map[string]any)
if b0["mode"] != "lacp" {
t.Fatalf("bond0 mode = %v", b0["mode"])
}
lacp := b0["lacp"].(map[string]any)
if lacp["mode"] != "active" || lacp["rate"] != "fast" || lacp["hash"] != "layer3-4" {
t.Fatalf("unexpected bond0 lacp: %#v", lacp)
}
bond1 := mustIfaceByName(t, ifaces, "bond1")
b1 := bond1["infix-interfaces:lag"].(map[string]any)
if b1["mode"] != "static" {
t.Fatalf("bond1 mode = %v", b1["mode"])
}
static := b1["static"].(map[string]any)
if static["mode"] != "balance-xor" || static["hash"] != "layer2" {
t.Fatalf("unexpected bond1 static: %#v", static)
}
}
func TestTransformBridgePortLowerLayer(t *testing.T) {
link := []map[string]any{{
"ifname": "eth2",
"ifindex": 30,
"flags": []any{"UP"},
"link_type": "ether",
"operstate": "UP",
"master": "br0",
"linkinfo": map[string]any{
"info_slave_kind": "bridge",
"info_slave_data": map[string]any{
"bcast_flood": true,
"flood": false,
"mcast_flood": true,
"fastleave": true,
"multicast_router": 2,
},
},
}}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
eth2 := mustIfaceByName(t, ifaces, "eth2")
lower := eth2["infix-interfaces:bridge-port"].(map[string]any)
if lower["bridge"] != "br0" {
t.Fatalf("bridge lower bridge = %v", lower["bridge"])
}
mcast := lower["multicast"].(map[string]any)
if mcast["router"] != "permanent" {
t.Fatalf("bridge router mode = %v", mcast["router"])
}
}
func TestTransformLagPortLowerLayer(t *testing.T) {
link := []map[string]any{{
"ifname": "eth3",
"ifindex": 31,
"flags": []any{"UP"},
"link_type": "ether",
"operstate": "UP",
"master": "bond0",
"linkinfo": map[string]any{
"info_slave_kind": "bond",
"info_slave_data": map[string]any{
"state": "ACTIVE",
"link_failure_count": 5,
"ad_aggregator_id": 42,
"ad_actor_oper_port_state_str": "collecting_distributing",
"ad_partner_oper_port_state_str": "collecting_distributing",
},
},
}}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
eth3 := mustIfaceByName(t, ifaces, "eth3")
lower := eth3["infix-interfaces:lag-port"].(map[string]any)
if lower["lag"] != "bond0" || lower["state"] != "active" || lower["link-failures"] != float64(5) {
t.Fatalf("unexpected lag-port lower-layer: %#v", lower)
}
lacp := lower["lacp"].(map[string]any)
if lacp["aggregator-id"] != float64(42) {
t.Fatalf("lag-port lacp aggregator-id = %v", lacp["aggregator-id"])
}
}
func TestTransformFilteredInterfaces(t *testing.T) {
link := []map[string]any{
{"ifname": "dummy0", "group": "internal", "link_type": "none"},
{"ifname": "can0", "link_type": "can"},
{"ifname": "vcan0", "link_type": "vcan"},
{"ifname": "eth9", "ifindex": 99, "flags": []any{"UP"}, "link_type": "ether", "operstate": "UP"},
}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
if len(ifaces) != 1 {
t.Fatalf("expected only one surviving interface, got %d", len(ifaces))
}
if ifaces[0]["name"] != "eth9" {
t.Fatalf("surviving interface = %v", ifaces[0]["name"])
}
}
func TestTransformWiFiType(t *testing.T) {
link := []map[string]any{{
"ifname": "wlan0",
"ifindex": 40,
"flags": []any{"UP"},
"link_type": "ether",
"operstate": "UP",
}}
fc := &mockFileChecker{exists: map[string]bool{"/sys/class/net/wlan0/wireless/": true}}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, fc))
wlan0 := mustIfaceByName(t, ifaces, "wlan0")
if wlan0["type"] != "infix-if-type:wifi" {
t.Fatalf("wlan0 type = %v", wlan0["type"])
}
}
func TestIplink2yangTypeMappings(t *testing.T) {
fc := &mockFileChecker{exists: map[string]bool{"/sys/class/net/wlan0/wireless/": true}}
tests := []struct {
name string
iplink map[string]any
want string
}{
{name: "loopback", iplink: map[string]any{"ifname": "lo", "link_type": "loopback"}, want: "infix-if-type:loopback"},
{name: "gre", iplink: map[string]any{"ifname": "gre0", "link_type": "gre"}, want: "infix-if-type:gre"},
{name: "gre6", iplink: map[string]any{"ifname": "gre6", "link_type": "gre6"}, want: "infix-if-type:gre"},
{name: "wifi via ether", iplink: map[string]any{"ifname": "wlan0", "link_type": "ether"}, want: "infix-if-type:wifi"},
{name: "bond", iplink: map[string]any{"ifname": "bond0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "bond"}}, want: "infix-if-type:lag"},
{name: "bridge", iplink: map[string]any{"ifname": "br0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "bridge"}}, want: "infix-if-type:bridge"},
{name: "dummy", iplink: map[string]any{"ifname": "dummy0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "dummy"}}, want: "infix-if-type:dummy"},
{name: "gretap", iplink: map[string]any{"ifname": "gretap0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "gretap"}}, want: "infix-if-type:gretap"},
{name: "vxlan", iplink: map[string]any{"ifname": "vxlan10", "link_type": "none", "linkinfo": map[string]any{"info_kind": "vxlan"}}, want: "infix-if-type:vxlan"},
{name: "veth", iplink: map[string]any{"ifname": "veth0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "veth"}}, want: "infix-if-type:veth"},
{name: "vlan", iplink: map[string]any{"ifname": "eth0.10", "link_type": "none", "linkinfo": map[string]any{"info_kind": "vlan"}}, want: "infix-if-type:vlan"},
{name: "wireguard", iplink: map[string]any{"ifname": "wg0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "wireguard"}}, want: "infix-if-type:wireguard"},
{name: "default ethernet", iplink: map[string]any{"ifname": "eth0", "link_type": "none", "linkinfo": map[string]any{"info_kind": "unknown"}}, want: "infix-if-type:ethernet"},
{name: "unknown link type", iplink: map[string]any{"ifname": "x", "link_type": "strange"}, want: "infix-if-type:other"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := iplink2yangType(tt.iplink, fc)
if got != tt.want {
t.Fatalf("iplink2yangType() = %q, want %q", got, tt.want)
}
})
}
}
func TestIplink2yangOperstateMappings(t *testing.T) {
tests := []struct {
in string
want string
}{
{"DOWN", "down"},
{"UP", "up"},
{"DORMANT", "dormant"},
{"TESTING", "testing"},
{"LOWERLAYERDOWN", "lower-layer-down"},
{"NOTPRESENT", "not-present"},
{"WHATEVER", "unknown"},
}
for _, tt := range tests {
if got := iplink2yangOperstate(tt.in); got != tt.want {
t.Fatalf("iplink2yangOperstate(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestSkipInterface(t *testing.T) {
tests := []struct {
name string
iplink map[string]any
want bool
}{
{name: "internal group", iplink: map[string]any{"group": "internal"}, want: true},
{name: "can", iplink: map[string]any{"link_type": "can"}, want: true},
{name: "vcan", iplink: map[string]any{"link_type": "vcan"}, want: true},
{name: "normal", iplink: map[string]any{"link_type": "ether"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := skipInterface(tt.iplink); got != tt.want {
t.Fatalf("skipInterface() = %v, want %v", got, tt.want)
}
})
}
}
func TestInet2yangOrigin(t *testing.T) {
tests := []struct {
name string
inet map[string]any
want string
}{
{name: "kernel_ll", inet: map[string]any{"protocol": "kernel_ll"}, want: "link-layer"},
{name: "kernel_ra", inet: map[string]any{"protocol": "kernel_ra"}, want: "link-layer"},
{name: "stable privacy kernel_ll", inet: map[string]any{"protocol": "kernel_ll", "stable-privacy": true}, want: "random"},
{name: "static", inet: map[string]any{"protocol": "static"}, want: "static"},
{name: "dhcp", inet: map[string]any{"protocol": "dhcp"}, want: "dhcp"},
{name: "random", inet: map[string]any{"protocol": "random"}, want: "random"},
{name: "other", inet: map[string]any{"protocol": "kernel_lo"}, want: "other"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := inet2yangOrigin(tt.inet); got != tt.want {
t.Fatalf("inet2yangOrigin() = %q, want %q", got, tt.want)
}
})
}
}
func TestProto2yang(t *testing.T) {
tests := []struct {
in string
want string
}{
{"802.1Q", "ieee802-dot1q-types:c-vlan"},
{"802.1ad", "ieee802-dot1q-types:s-vlan"},
{"something", "other"},
}
for _, tt := range tests {
if got := proto2yang(tt.in); got != tt.want {
t.Fatalf("proto2yang(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestLagMode(t *testing.T) {
tests := []struct {
in string
want string
}{
{"802.3ad", "lacp"},
{"balance-xor", "static"},
{"active-backup", "static"},
}
for _, tt := range tests {
if got := lagMode(tt.in); got != tt.want {
t.Fatalf("lagMode(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestLagHash(t *testing.T) {
tests := []struct {
in string
want string
}{
{"layer2", "layer2"},
{"layer3+4", "layer3-4"},
{"layer2+3", "layer2-3"},
{"encap2+3", "encap2-3"},
{"encap3+4", "encap3-4"},
{"vlan+srcmac", "vlan-srcmac"},
{"something-else", "layer2"},
}
for _, tt := range tests {
if got := lagHash(tt.in); got != tt.want {
t.Fatalf("lagHash(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestBridgeRouterMode(t *testing.T) {
tests := []struct {
in int
want string
}{
{0, "off"},
{1, "auto"},
{2, "permanent"},
{9, "UNKNOWN"},
}
for _, tt := range tests {
if got := bridgeRouterMode(tt.in); got != tt.want {
t.Fatalf("bridgeRouterMode(%d) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestStatistics(t *testing.T) {
t.Run("with stats64", func(t *testing.T) {
st := statistics(map[string]any{
"stats64": map[string]any{
"rx": map[string]any{"bytes": json.Number("123")},
"tx": map[string]any{"bytes": uint64(456)},
},
})
if st["in-octets"] != "123" || st["out-octets"] != "456" {
t.Fatalf("unexpected statistics map: %#v", st)
}
})
t.Run("without stats64", func(t *testing.T) {
st := statistics(map[string]any{})
if len(st) != 0 {
t.Fatalf("expected empty stats, got %#v", st)
}
})
}
func TestToCounterString(t *testing.T) {
tests := []struct {
name string
in any
want string
}{
{name: "int", in: int(7), want: "7"},
{name: "int64", in: int64(8), want: "8"},
{name: "uint64", in: uint64(9), want: "9"},
{name: "float64", in: float64(10.9), want: "10"},
{name: "json number", in: json.Number("11"), want: "11"},
{name: "string", in: " 12 ", want: "12"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := toCounterString(tt.in); got != tt.want {
t.Fatalf("toCounterString(%v) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestAddressesFamilyFilter(t *testing.T) {
ipaddr := map[string]any{
"addr_info": []any{
map[string]any{"family": "inet", "local": "192.0.2.1", "prefixlen": 24, "protocol": "dhcp"},
map[string]any{"family": "inet6", "local": "2001:db8::1", "prefixlen": 64, "protocol": "kernel_ra"},
},
}
v4 := addresses(ipaddr, "inet")
if len(v4) != 1 || v4[0]["ip"] != "192.0.2.1" || v4[0]["prefix-length"] != 24 || v4[0]["origin"] != "dhcp" {
t.Fatalf("unexpected inet addresses: %#v", v4)
}
v6 := addresses(ipaddr, "inet6")
if len(v6) != 1 || v6[0]["ip"] != "2001:db8::1" || v6[0]["prefix-length"] != 64 || v6[0]["origin"] != "link-layer" {
t.Fatalf("unexpected inet6 addresses: %#v", v6)
}
}
func TestIPv4Data(t *testing.T) {
t.Run("with mtu and addresses", func(t *testing.T) {
in := map[string]any{
"ifname": "eth0",
"mtu": 1500,
"addr_info": []any{
map[string]any{"family": "inet", "local": "10.0.0.1", "prefixlen": 24, "protocol": "static"},
},
}
out := ipv4Data(in)
if out["mtu"] != 1500 {
t.Fatalf("unexpected mtu: %#v", out)
}
if _, ok := out["address"]; !ok {
t.Fatalf("missing address list: %#v", out)
}
})
t.Run("without mtu", func(t *testing.T) {
in := map[string]any{
"ifname": "eth0",
"addr_info": []any{
map[string]any{"family": "inet", "local": "10.0.0.2", "prefixlen": 24, "protocol": "static"},
},
}
out := ipv4Data(in)
if _, ok := out["mtu"]; ok {
t.Fatalf("did not expect mtu in %#v", out)
}
})
t.Run("loopback omits mtu", func(t *testing.T) {
in := map[string]any{"ifname": "lo", "mtu": 65536}
out := ipv4Data(in)
if _, ok := out["mtu"]; ok {
t.Fatalf("loopback must not include mtu: %#v", out)
}
})
}
func TestIPv6Data(t *testing.T) {
t.Run("with mtu and addresses", func(t *testing.T) {
in := map[string]any{
"ifname": "eth0",
"addr_info": []any{
map[string]any{"family": "inet6", "local": "2001:db8::1", "prefixlen": 64, "protocol": "static"},
},
}
fc := &mockFileChecker{files: map[string]string{"/proc/sys/net/ipv6/conf/eth0/mtu": "1280\n"}}
out := ipv6Data(in, fc)
if out["mtu"] != 1280 {
t.Fatalf("unexpected mtu: %#v", out)
}
if _, ok := out["address"]; !ok {
t.Fatalf("missing address list: %#v", out)
}
})
t.Run("without mtu from filechecker", func(t *testing.T) {
in := map[string]any{"ifname": "eth1"}
fc := &mockFileChecker{readErr: map[string]error{"/proc/sys/net/ipv6/conf/eth1/mtu": errors.New("no file")}}
out := ipv6Data(in, fc)
if _, ok := out["mtu"]; ok {
t.Fatalf("did not expect mtu in %#v", out)
}
})
t.Run("without addresses", func(t *testing.T) {
in := map[string]any{"ifname": "eth2"}
out := ipv6Data(in, nil)
if len(out) != 0 {
t.Fatalf("expected empty ipv6 map, got %#v", out)
}
})
}
func TestDedupByIfindex(t *testing.T) {
t.Run("keeps UP over DOWN for same ifindex", func(t *testing.T) {
link := []map[string]any{
{"ifindex": 2, "ifname": "eth0", "flags": []any{}, "link_type": "ether", "operstate": "DOWN", "address": "02:00:00:00:00:01"},
{"ifindex": 2, "ifname": "e1", "flags": []any{"UP"}, "link_type": "ether", "operstate": "UP", "address": "02:00:00:00:00:01"},
}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
if len(ifaces) != 1 {
t.Fatalf("expected 1 interface after dedup, got %d", len(ifaces))
}
if ifaces[0]["name"] != "e1" {
t.Fatalf("expected e1 to survive dedup, got %v", ifaces[0]["name"])
}
})
t.Run("keeps first when both DOWN", func(t *testing.T) {
link := []map[string]any{
{"ifindex": 3, "ifname": "a0", "flags": []any{}, "link_type": "ether", "operstate": "DOWN"},
{"ifindex": 3, "ifname": "a1", "flags": []any{}, "link_type": "ether", "operstate": "DOWN"},
}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
if len(ifaces) != 1 {
t.Fatalf("expected 1 interface after dedup, got %d", len(ifaces))
}
if ifaces[0]["name"] != "a0" {
t.Fatalf("expected a0 to survive dedup, got %v", ifaces[0]["name"])
}
})
t.Run("different ifindex not deduped", func(t *testing.T) {
link := []map[string]any{
{"ifindex": 1, "ifname": "lo", "flags": []any{"LOOPBACK", "UP"}, "link_type": "loopback", "operstate": "UNKNOWN"},
{"ifindex": 2, "ifname": "e1", "flags": []any{"UP"}, "link_type": "ether", "operstate": "UP"},
}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
if len(ifaces) != 2 {
t.Fatalf("expected 2 interfaces, got %d", len(ifaces))
}
})
t.Run("zero ifindex entries kept as-is", func(t *testing.T) {
link := []map[string]any{
{"ifname": "x0", "flags": []any{"UP"}, "link_type": "ether", "operstate": "UP"},
{"ifname": "x1", "flags": []any{"UP"}, "link_type": "ether", "operstate": "UP"},
}
ifaces := mustInterfaces(t, Transform(mustRaw(t, link), nil, nil, nil))
if len(ifaces) != 2 {
t.Fatalf("expected 2 interfaces (zero ifindex not deduped), got %d", len(ifaces))
}
})
}
+213
View File
@@ -0,0 +1,213 @@
// Package ipbatch manages a persistent `ip -json -s -d -force -batch -`
// subprocess. Commands sent via Query are serialized by a mutex and
// paired with the single JSON-array line the subprocess writes to
// stdout. The -s and -d global flags ensure link queries include
// statistics and details. On subprocess death the manager enters a
// dead state and attempts automatic restart with exponential backoff.
package ipbatch
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.
// Callers should treat it as transient and retry on the next event.
var ErrBatchDead = errors.New("ip batch process is dead")
const (
canaryCommand = "link show lo"
reconnectInitial = 100 * time.Millisecond
reconnectMax = 30 * time.Second
reconnectFactor = 2.0
)
// IPBatch wraps a persistent `ip -json -force -batch -` subprocess.
type IPBatch struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Scanner
stderr io.ReadCloser
mu sync.Mutex // serializes queries
alive atomic.Bool
log *slog.Logger
ctx context.Context
cancel context.CancelFunc
}
// New spawns the ip batch subprocess. The returned IPBatch is ready
// for Query calls. A background goroutine drains stderr.
func New(ctx context.Context, log *slog.Logger) (*IPBatch, error) {
ctx, cancel := context.WithCancel(ctx)
b := &IPBatch{
log: log,
ctx: ctx,
cancel: cancel,
}
if err := b.start(); err != nil {
cancel()
return nil, err
}
go b.restartLoop()
return b, nil
}
func (b *IPBatch) start() error {
cmd := exec.CommandContext(b.ctx, "ip", "-json", "-s", "-d", "-force", "-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 ip batch: %w", err)
}
b.mu.Lock()
b.cmd = cmd
b.stdin = stdin
b.stdout = bufio.NewScanner(stdout)
b.stdout.Buffer(make([]byte, 0, 4*1024*1024), 4*1024*1024) // 4 MiB max line
b.stderr = stderr
b.alive.Store(true)
b.mu.Unlock()
go b.drainStderr()
return nil
}
// Query sends a command to the ip batch process and returns the JSON
// response. Commands are newline-terminated (e.g. "link show dev eth0").
// Each command produces exactly one line of JSON array output.
func (b *IPBatch) 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)
}
if !b.stdout.Scan() {
b.alive.Store(false)
if err := b.stdout.Err(); err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return nil, fmt.Errorf("ip batch process exited")
}
raw := make([]byte, len(b.stdout.Bytes()))
copy(raw, b.stdout.Bytes())
return json.RawMessage(raw), nil
}
// Close terminates the subprocess and cancels the restart loop.
func (b *IPBatch) 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", "restarting", or "failed".
func (b *IPBatch) Status() string {
if b.alive.Load() {
return "running"
}
return "restarting"
}
func (b *IPBatch) drainStderr() {
scanner := bufio.NewScanner(b.stderr)
for scanner.Scan() {
b.log.Warn("ip batch stderr", "line", scanner.Text())
}
}
// restartLoop runs in the background and respawns the subprocess when
// it dies. Uses exponential backoff: 100ms initial, 30s max, 2x factor.
// After a successful restart, a canary query validates the new process.
func (b *IPBatch) restartLoop() {
delay := reconnectInitial
for {
select {
case <-b.ctx.Done():
return
default:
}
if b.alive.Load() {
// Wait for death or context cancellation.
// Poll periodically since there's no notification channel.
select {
case <-b.ctx.Done():
return
case <-time.After(200 * time.Millisecond):
continue
}
}
b.log.Info("ip batch: subprocess died, restarting", "delay", delay)
select {
case <-b.ctx.Done():
return
case <-time.After(delay):
}
// Kill old process if lingering.
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("ip batch: restart failed", "err", err)
delay = time.Duration(math.Min(
float64(delay)*reconnectFactor,
float64(reconnectMax)))
continue
}
// Canary query to validate the new process.
if _, err := b.Query(canaryCommand); err != nil {
b.log.Warn("ip batch: canary query failed", "err", err)
b.alive.Store(false)
delay = time.Duration(math.Min(
float64(delay)*reconnectFactor,
float64(reconnectMax)))
continue
}
b.log.Info("ip batch: restarted successfully")
delay = reconnectInitial
}
}
+56
View File
@@ -0,0 +1,56 @@
package ipc
import (
"encoding/json"
"fmt"
"net"
"time"
)
// Client connects to a yangerd Unix socket and issues IPC requests.
type Client struct {
addr string
timeout time.Duration
}
// NewClient returns a Client that connects to the given socket path
// with per-request timeout.
func NewClient(socketPath string, timeout time.Duration) *Client {
return &Client{
addr: socketPath,
timeout: timeout,
}
}
// Get queries a YANG subtree by path. Path "/" returns all models.
func (c *Client) Get(path string) (*Response, error) {
return c.call(&Request{Method: "get", Path: path})
}
// Health returns per-model freshness metadata.
func (c *Client) Health() (*Response, error) {
return c.call(&Request{Method: "health"})
}
func (c *Client) call(req *Request) (*Response, error) {
conn, err := net.DialTimeout("unix", c.addr, c.timeout)
if err != nil {
return nil, fmt.Errorf("connect %s: %w", c.addr, err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(c.timeout))
payload, err := json.Marshal(req)
if err != nil {
return nil, err
}
if err := WriteFrame(conn, payload); err != nil {
return nil, fmt.Errorf("write request: %w", err)
}
resp, err := ReadResponse(conn)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return resp, nil
}
+116
View File
@@ -0,0 +1,116 @@
// Package ipc implements the yangerd IPC protocol: a versioned,
// length-prefixed JSON framing over AF_UNIX SOCK_STREAM.
//
// Wire format:
//
// +--------+--------+--------+--------+--------+--- ... ---+
// | ver(1) | length (uint32 big-endian, bytes) | JSON body |
// +--------+--------+--------+--------+--------+--- ... ---+
package ipc
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
)
const (
// Version is the current protocol version.
Version byte = 1
// MaxPayload is the maximum JSON body size (4 MiB).
MaxPayload = 4 << 20
headerSize = 5 // 1 byte version + 4 bytes length
)
// Request is the IPC request from a client.
type Request struct {
Method string `json:"method"`
Path string `json:"path,omitempty"`
Filter map[string]string `json:"filter,omitempty"`
}
// Response is the IPC response to a client.
type Response struct {
Status string `json:"status"`
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
// Used by "get" responses.
Data json.RawMessage `json:"data,omitempty"`
// Used by "health" responses.
Subsystems map[string]json.RawMessage `json:"subsystems,omitempty"`
Models map[string]json.RawMessage `json:"models,omitempty"`
}
// WriteFrame writes a versioned, length-prefixed frame to w.
func WriteFrame(w io.Writer, payload []byte) error {
if len(payload) > MaxPayload {
return fmt.Errorf("payload size %d exceeds maximum %d", len(payload), MaxPayload)
}
hdr := [headerSize]byte{Version}
binary.BigEndian.PutUint32(hdr[1:], uint32(len(payload)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
_, err := w.Write(payload)
return err
}
// ReadFrame reads a versioned, length-prefixed frame from r.
func ReadFrame(r io.Reader) ([]byte, error) {
var hdr [headerSize]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return nil, err
}
if hdr[0] != Version {
return nil, fmt.Errorf("protocol version mismatch: got %d, want %d", hdr[0], Version)
}
length := binary.BigEndian.Uint32(hdr[1:])
if length > MaxPayload {
return nil, fmt.Errorf("payload size %d exceeds maximum %d", length, MaxPayload)
}
buf := make([]byte, length)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
return buf, nil
}
// WriteResponse marshals a Response and writes it as a framed message.
func WriteResponse(w io.Writer, resp *Response) error {
data, err := json.Marshal(resp)
if err != nil {
return err
}
return WriteFrame(w, data)
}
// ReadRequest reads and unmarshals a framed Request.
func ReadRequest(r io.Reader) (*Request, error) {
data, err := ReadFrame(r)
if err != nil {
return nil, err
}
var req Request
if err := json.Unmarshal(data, &req); err != nil {
return nil, fmt.Errorf("invalid request JSON: %w", err)
}
return &req, nil
}
// ReadResponse reads and unmarshals a framed Response.
func ReadResponse(r io.Reader) (*Response, error) {
data, err := ReadFrame(r)
if err != nil {
return nil, err
}
var resp Response
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("invalid response JSON: %w", err)
}
return &resp, nil
}
+89
View File
@@ -0,0 +1,89 @@
package ipc
import (
"bytes"
"encoding/json"
"testing"
)
func TestFrameRoundTrip(t *testing.T) {
payload := []byte(`{"method":"get","path":"/test"}`)
var buf bytes.Buffer
if err := WriteFrame(&buf, payload); err != nil {
t.Fatal(err)
}
got, err := ReadFrame(&buf)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("mismatch: %s vs %s", got, payload)
}
}
func TestFrameVersionMismatch(t *testing.T) {
var buf bytes.Buffer
buf.Write([]byte{99, 0, 0, 0, 2, '{', '}'})
_, err := ReadFrame(&buf)
if err == nil {
t.Fatal("expected version mismatch error")
}
}
func TestFrameOversized(t *testing.T) {
var buf bytes.Buffer
huge := make([]byte, MaxPayload+1)
if err := WriteFrame(&buf, huge); err == nil {
t.Fatal("expected oversized payload error")
}
}
func TestRequestResponseRoundTrip(t *testing.T) {
var buf bytes.Buffer
req := &Request{Method: "get", Path: "/ietf-system:system-state"}
data, _ := json.Marshal(req)
WriteFrame(&buf, data)
got, err := ReadRequest(&buf)
if err != nil {
t.Fatal(err)
}
if got.Method != "get" || got.Path != "/ietf-system:system-state" {
t.Fatalf("unexpected request: %+v", got)
}
}
func TestResponseRoundTrip(t *testing.T) {
var buf bytes.Buffer
resp := &Response{
Status: "ok",
Data: json.RawMessage(`{"hostname":"r1"}`),
}
WriteResponse(&buf, resp)
got, err := ReadResponse(&buf)
if err != nil {
t.Fatal(err)
}
if got.Status != "ok" || string(got.Data) != `{"hostname":"r1"}` {
t.Fatalf("unexpected response: %+v", got)
}
}
func TestEmptyFrame(t *testing.T) {
var buf bytes.Buffer
WriteFrame(&buf, []byte{})
got, err := ReadFrame(&buf)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("expected empty, got %d bytes", len(got))
}
}
+210
View File
@@ -0,0 +1,210 @@
package ipc
import (
"context"
"encoding/json"
"log"
"net"
"os"
"sync"
"sync/atomic"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
// Server listens on an AF_UNIX SOCK_STREAM socket and serves
// YANG operational data from an in-memory Tree.
type Server struct {
tree *tree.Tree
listener net.Listener
ready *atomic.Bool
wg sync.WaitGroup
}
// NewServer creates a Server that serves data from the given Tree.
// While ready is false, all requests receive a 503 "starting" response.
func NewServer(t *tree.Tree, ready *atomic.Bool) *Server {
return &Server{
tree: t,
ready: ready,
}
}
// Listen creates and binds a Unix domain socket at path.
// A stale socket file is removed before binding.
func (s *Server) Listen(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
ln, err := net.Listen("unix", path)
if err != nil {
return err
}
if err := os.Chmod(path, 0660); err != nil {
ln.Close()
return err
}
s.listener = ln
return nil
}
// Serve accepts connections until ctx is cancelled. Each connection
// is handled in its own goroutine.
func (s *Server) Serve(ctx context.Context) error {
go func() {
<-ctx.Done()
s.listener.Close()
}()
for {
conn, err := s.listener.Accept()
if err != nil {
// Listener closed by context cancellation — normal shutdown.
select {
case <-ctx.Done():
s.wg.Wait()
return nil
default:
return err
}
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.handleConn(conn)
}()
}
}
// Addr returns the listener address, or empty string if not listening.
func (s *Server) Addr() string {
if s.listener == nil {
return ""
}
return s.listener.Addr().String()
}
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
req, err := ReadRequest(conn)
if err != nil {
log.Printf("ipc: read request: %v", err)
return
}
if !s.ready.Load() {
WriteResponse(conn, &Response{
Status: "starting",
Code: 503,
Message: "yangerd is starting up",
})
return
}
switch req.Method {
case "get":
s.handleGet(conn, req)
case "health":
s.handleHealth(conn)
default:
WriteResponse(conn, &Response{
Status: "error",
Code: 400,
Message: "unknown method: " + req.Method,
})
}
}
func (s *Server) handleGet(conn net.Conn, req *Request) {
path := req.Path
if path == "" || path == "/" {
s.handleDump(conn)
return
}
key := path
if key[0] == '/' {
key = key[1:]
}
data := s.tree.Get(key)
if data == nil {
WriteResponse(conn, &Response{
Status: "error",
Code: 404,
Message: "path not found: " + path,
})
return
}
envelope := map[string]json.RawMessage{key: data}
body, err := json.Marshal(envelope)
if err != nil {
WriteResponse(conn, &Response{
Status: "error",
Code: 500,
Message: "marshal error: " + err.Error(),
})
return
}
WriteResponse(conn, &Response{
Status: "ok",
Data: body,
})
}
func (s *Server) handleDump(conn net.Conn) {
keys := s.tree.Keys()
blobs := s.tree.GetMulti(keys)
all := make(map[string]json.RawMessage, len(keys))
for i, k := range keys {
if i < len(blobs) {
all[k] = blobs[i]
}
}
body, err := json.Marshal(all)
if err != nil {
WriteResponse(conn, &Response{
Status: "error",
Code: 500,
Message: "marshal error: " + err.Error(),
})
return
}
WriteResponse(conn, &Response{
Status: "ok",
Data: body,
})
}
func (s *Server) handleHealth(conn net.Conn) {
keys := s.tree.Keys()
models := make(map[string]json.RawMessage, len(keys))
for _, k := range keys {
info, ok := s.tree.Info(k)
if !ok {
continue
}
entry := struct {
LastUpdated string `json:"last_updated"`
SizeBytes int `json:"size_bytes"`
}{
LastUpdated: info.LastUpdated.UTC().Format(time.RFC3339),
SizeBytes: info.SizeBytes,
}
b, _ := json.Marshal(entry)
models[k] = b
}
WriteResponse(conn, &Response{
Status: "ok",
Models: models,
})
}
+135
View File
@@ -0,0 +1,135 @@
package ipc
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
func TestServerGetSingle(t *testing.T) {
tr := tree.New()
tr.Set("ietf-system:system-state", json.RawMessage(`{"platform":{"os-name":"Infix"}}`))
resp := serverRoundTrip(t, tr, true, &Request{Method: "get", Path: "/ietf-system:system-state"})
if resp.Status != "ok" {
t.Fatalf("expected ok, got %s: %s", resp.Status, resp.Message)
}
var data map[string]json.RawMessage
json.Unmarshal(resp.Data, &data)
if _, ok := data["ietf-system:system-state"]; !ok {
t.Fatalf("missing key in response data: %s", resp.Data)
}
}
func TestServerGetNotFound(t *testing.T) {
tr := tree.New()
resp := serverRoundTrip(t, tr, true, &Request{Method: "get", Path: "/nonexistent"})
if resp.Status != "error" || resp.Code != 404 {
t.Fatalf("expected 404 error, got %+v", resp)
}
}
func TestServerDump(t *testing.T) {
tr := tree.New()
tr.Set("a", json.RawMessage(`1`))
tr.Set("b", json.RawMessage(`2`))
resp := serverRoundTrip(t, tr, true, &Request{Method: "get", Path: "/"})
if resp.Status != "ok" {
t.Fatalf("expected ok, got %s: %s", resp.Status, resp.Message)
}
var data map[string]json.RawMessage
json.Unmarshal(resp.Data, &data)
if len(data) != 2 {
t.Fatalf("expected 2 models in dump, got %d", len(data))
}
}
func TestServerHealth(t *testing.T) {
tr := tree.New()
tr.Set("model-a", json.RawMessage(`{}`))
resp := serverRoundTrip(t, tr, true, &Request{Method: "health"})
if resp.Status != "ok" {
t.Fatalf("expected ok, got %s", resp.Status)
}
if _, ok := resp.Models["model-a"]; !ok {
t.Fatalf("expected model-a in health models, got %v", resp.Models)
}
}
func TestServerNotReady(t *testing.T) {
tr := tree.New()
resp := serverRoundTrip(t, tr, false, &Request{Method: "get", Path: "/"})
if resp.Status != "starting" || resp.Code != 503 {
t.Fatalf("expected 503 starting, got %+v", resp)
}
}
func TestServerUnknownMethod(t *testing.T) {
tr := tree.New()
resp := serverRoundTrip(t, tr, true, &Request{Method: "invalid"})
if resp.Status != "error" || resp.Code != 400 {
t.Fatalf("expected 400 error, got %+v", resp)
}
}
func serverRoundTrip(t *testing.T, tr *tree.Tree, ready bool, req *Request) *Response {
t.Helper()
sockPath := filepath.Join(t.TempDir(), "test.sock")
readyFlag := &atomic.Bool{}
readyFlag.Store(ready)
srv := NewServer(tr, readyFlag)
if err := srv.Listen(sockPath); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- srv.Serve(ctx)
}()
time.Sleep(10 * time.Millisecond)
conn, err := net.Dial("unix", sockPath)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
payload, _ := json.Marshal(req)
if err := WriteFrame(conn, payload); err != nil {
t.Fatal(err)
}
resp, err := ReadResponse(conn)
if err != nil {
t.Fatal(err)
}
cancel()
if _, err := os.Stat(sockPath); err == nil {
os.Remove(sockPath)
}
return resp
}
+331
View File
@@ -0,0 +1,331 @@
// Package iwmonitor manages a persistent `iw event -t` subprocess
// for reactive 802.11 wireless monitoring. Events are parsed from
// the human-readable text output and dispatched to re-query handlers.
// Started only when YANGERD_ENABLE_WIFI=true.
package iwmonitor
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"os/exec"
"strconv"
"strings"
"time"
)
const (
reconnectInitial = 100 * time.Millisecond
reconnectMax = 30 * time.Second
reconnectFactor = 2.0
queryTimeout = 5 * time.Second
)
// IWEvent represents a single parsed line from `iw event -t`.
type IWEvent struct {
Timestamp float64
Interface string
Phy string
Type string
Addr string
}
// IWMonitor subscribes to WiFi events via `iw event -t` and updates
// the tree when stations associate/disassociate, links connect/
// disconnect, or regulatory domains change.
type IWMonitor struct {
log *slog.Logger
onUpdate func(ifname string, data json.RawMessage)
}
// New creates an IWMonitor.
func New(log *slog.Logger) *IWMonitor {
return &IWMonitor{log: log}
}
// SetOnUpdate sets the callback invoked when wifi data changes for
// an interface. The data is a JSON object with station/info fields.
func (m *IWMonitor) SetOnUpdate(fn func(string, json.RawMessage)) {
m.onUpdate = fn
}
// Run starts the iw event monitor. It blocks until ctx is cancelled.
// On subprocess exit it reconnects with exponential backoff.
func (m *IWMonitor) Run(ctx context.Context) error {
delay := reconnectInitial
for {
err := m.runOnce(ctx)
if ctx.Err() != nil {
return ctx.Err()
}
m.log.Warn("iw event: subprocess exited, restarting",
"err", err, "delay", delay)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
delay = time.Duration(math.Min(
float64(delay)*reconnectFactor,
float64(reconnectMax)))
}
}
func (m *IWMonitor) runOnce(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "iw", "event", "-t")
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start iw event: %w", err)
}
defer cmd.Wait()
m.refreshAllInterfaces(ctx)
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
ev, ok := ParseIWEvent(scanner.Text())
if !ok {
continue
}
m.handleEvent(ctx, ev)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read iw event: %w", err)
}
return fmt.Errorf("iw event process exited")
}
func (m *IWMonitor) handleEvent(ctx context.Context, ev IWEvent) {
switch ev.Type {
case "new station", "del station":
m.refreshStationList(ctx, ev.Interface)
case "connected", "ch_switch_started_notify":
m.refreshInterfaceInfo(ctx, ev.Interface)
case "disconnected":
m.publishWifi(ev.Interface, json.RawMessage(`{}`), nil)
case "reg_change":
m.refreshAllInterfaces(ctx)
default:
m.log.Debug("iw event: unhandled", "type", ev.Type, "iface", ev.Interface)
}
}
func (m *IWMonitor) refreshStationList(ctx context.Context, iface string) {
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "iw", "dev", iface, "station", "dump").Output()
if err != nil {
m.log.Warn("iw station dump failed", "iface", iface, "err", err)
return
}
stations := parseStationDump(string(out))
m.publishWifi(iface, nil, stations)
}
func (m *IWMonitor) refreshInterfaceInfo(ctx context.Context, iface string) {
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "iw", "dev", iface, "info").Output()
if err != nil {
m.log.Warn("iw dev info failed", "iface", iface, "err", err)
return
}
info := parseIWInfo(string(out))
m.publishWifi(iface, info, nil)
}
func (m *IWMonitor) publishWifi(iface string, info, stations json.RawMessage) {
if m.onUpdate == nil {
return
}
wifi := make(map[string]json.RawMessage)
if info != nil {
wifi["info"] = info
}
if stations != nil {
wifi["stations"] = stations
}
raw, err := json.Marshal(wifi)
if err != nil {
m.log.Warn("iwmonitor: marshal wifi data", "iface", iface, "err", err)
return
}
m.onUpdate(iface, json.RawMessage(raw))
}
func (m *IWMonitor) refreshAllInterfaces(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "iw", "dev").Output()
if err != nil {
m.log.Warn("iw dev list failed", "err", err)
return
}
for _, iface := range parseIWDevList(string(out)) {
m.refreshInterfaceInfo(ctx, iface)
m.refreshStationList(ctx, iface)
}
}
// ParseIWEvent parses a single line from `iw event -t` output.
func ParseIWEvent(line string) (IWEvent, bool) {
parts := strings.SplitN(line, ": ", 3)
if len(parts) < 3 {
return IWEvent{}, false
}
ts, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return IWEvent{}, false
}
ifacePhy := parts[1]
parenIdx := strings.Index(ifacePhy, " (")
if parenIdx < 0 {
return IWEvent{}, false
}
iface := ifacePhy[:parenIdx]
phy := strings.Trim(ifacePhy[parenIdx+2:], ")")
eventStr := parts[2]
ev := IWEvent{Timestamp: ts, Interface: iface, Phy: phy}
switch {
case strings.HasPrefix(eventStr, "new station "):
ev.Type = "new station"
ev.Addr = strings.TrimPrefix(eventStr, "new station ")
case strings.HasPrefix(eventStr, "del station "):
ev.Type = "del station"
ev.Addr = strings.TrimPrefix(eventStr, "del station ")
case strings.HasPrefix(eventStr, "connected to "):
ev.Type = "connected"
ev.Addr = strings.TrimPrefix(eventStr, "connected to ")
case eventStr == "disconnected":
ev.Type = "disconnected"
case strings.HasPrefix(eventStr, "ch_switch_started_notify"):
ev.Type = "ch_switch_started_notify"
case eventStr == "scan started":
ev.Type = "scan started"
case eventStr == "scan aborted":
ev.Type = "scan aborted"
case strings.HasPrefix(eventStr, "reg_change"):
ev.Type = "reg_change"
case strings.HasPrefix(eventStr, "auth"):
ev.Type = "auth"
default:
ev.Type = eventStr
}
return ev, true
}
// parseStationDump parses `iw dev <iface> station dump` output into JSON.
func parseStationDump(output string) json.RawMessage {
type station struct {
MAC string `json:"mac"`
Signal string `json:"signal,omitempty"`
RxBytes string `json:"rx-bytes,omitempty"`
TxBytes string `json:"tx-bytes,omitempty"`
Connected string `json:"connected-time,omitempty"`
Inactive string `json:"inactive-time,omitempty"`
RxBitrate string `json:"rx-bitrate,omitempty"`
TxBitrate string `json:"tx-bitrate,omitempty"`
Authorized string `json:"authorized,omitempty"`
}
var stations []station
var current *station
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Station ") {
parts := strings.Fields(line)
if len(parts) >= 2 {
s := station{MAC: parts[1]}
stations = append(stations, s)
current = &stations[len(stations)-1]
}
continue
}
if current == nil {
continue
}
if k, v, ok := parseKV(line); ok {
switch k {
case "signal":
current.Signal = v
case "rx bytes":
current.RxBytes = v
case "tx bytes":
current.TxBytes = v
case "connected time":
current.Connected = v
case "inactive time":
current.Inactive = v
case "rx bitrate":
current.RxBitrate = v
case "tx bitrate":
current.TxBitrate = v
case "authorized":
current.Authorized = v
}
}
}
data, _ := json.Marshal(stations)
return json.RawMessage(data)
}
// parseIWInfo parses `iw dev <iface> info` output into JSON.
func parseIWInfo(output string) json.RawMessage {
info := make(map[string]string)
for _, line := range strings.Split(output, "\n") {
if k, v, ok := parseKV(strings.TrimSpace(line)); ok {
switch k {
case "ssid":
info["ssid"] = v
case "type":
info["type"] = v
case "channel":
info["channel"] = v
case "txpower":
info["tx-power"] = v
}
}
}
data, _ := json.Marshal(info)
return json.RawMessage(data)
}
// parseIWDevList extracts interface names from `iw dev` output.
func parseIWDevList(output string) []string {
var ifaces []string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Interface ") {
if name := strings.TrimPrefix(line, "Interface "); name != "" {
ifaces = append(ifaces, name)
}
}
}
return ifaces
}
func parseKV(line string) (string, string, bool) {
idx := strings.Index(line, ":")
if idx < 0 {
return "", "", false
}
k := strings.TrimSpace(line[:idx])
v := strings.TrimSpace(line[idx+1:])
return k, v, k != ""
}
@@ -0,0 +1,221 @@
package iwmonitor
import (
"encoding/json"
"reflect"
"testing"
)
func TestParseIWEvent(t *testing.T) {
tests := []struct {
name string
line string
wantOK bool
wantType string
wantIface string
wantPhy string
wantAddr string
}{
{
name: "new station",
line: "1234567890.123456: wlan0 (phy#0): new station aa:bb:cc:dd:ee:ff",
wantOK: true,
wantType: "new station",
wantIface: "wlan0",
wantPhy: "phy#0",
wantAddr: "aa:bb:cc:dd:ee:ff",
},
{
name: "del station",
line: "1234567890.123456: wlan0 (phy#0): del station aa:bb:cc:dd:ee:ff",
wantOK: true,
wantType: "del station",
wantIface: "wlan0",
wantPhy: "phy#0",
wantAddr: "aa:bb:cc:dd:ee:ff",
},
{
name: "connected",
line: "1234567890.123456: wlan0 (phy#0): connected to aa:bb:cc:dd:ee:ff",
wantOK: true,
wantType: "connected",
wantIface: "wlan0",
wantPhy: "phy#0",
wantAddr: "aa:bb:cc:dd:ee:ff",
},
{
name: "disconnected",
line: "1234567890.123456: wlan0 (phy#0): disconnected",
wantOK: true,
wantType: "disconnected",
wantIface: "wlan0",
wantPhy: "phy#0",
},
{
name: "channel switch",
line: "1234567890.123456: wlan0 (phy#0): ch_switch_started_notify",
wantOK: true,
wantType: "ch_switch_started_notify",
wantIface: "wlan0",
wantPhy: "phy#0",
},
{
name: "scan started",
line: "1234567890.123456: wlan0 (phy#0): scan started",
wantOK: true,
wantType: "scan started",
wantIface: "wlan0",
wantPhy: "phy#0",
},
{
name: "reg change",
line: "1234567890.123456: wlan0 (phy#0): reg_change",
wantOK: true,
wantType: "reg_change",
wantIface: "wlan0",
wantPhy: "phy#0",
},
{
name: "malformed missing separators",
line: "1234567890.123456 wlan0 (phy#0) new station aa:bb:cc:dd:ee:ff",
wantOK: false,
},
{
name: "malformed bad timestamp",
line: "not-a-float: wlan0 (phy#0): disconnected",
wantOK: false,
},
{
name: "malformed missing phy",
line: "1234567890.123456: wlan0 phy#0: disconnected",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ParseIWEvent(tt.line)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if !tt.wantOK {
return
}
if got.Type != tt.wantType {
t.Fatalf("Type = %q, want %q", got.Type, tt.wantType)
}
if got.Interface != tt.wantIface {
t.Fatalf("Interface = %q, want %q", got.Interface, tt.wantIface)
}
if got.Phy != tt.wantPhy {
t.Fatalf("Phy = %q, want %q", got.Phy, tt.wantPhy)
}
if got.Addr != tt.wantAddr {
t.Fatalf("Addr = %q, want %q", got.Addr, tt.wantAddr)
}
})
}
}
func TestParseStationDump(t *testing.T) {
input := `Station aa:bb:cc:dd:ee:ff (on wlan0)
inactive time: 10 ms
rx bytes: 1234
tx bytes: 5678
connected time: 42 seconds
signal: -40 dBm
rx bitrate: 6.5 MBit/s
tx bitrate: 130.0 MBit/s
authorized: yes
Station 11:22:33:44:55:66 (on wlan0)
inactive time: 20 ms
rx bytes: 9876
tx bytes: 5432
connected time: 84 seconds
signal: -55 dBm
authorized: no`
got := parseStationDump(input)
var gotDecoded []map[string]string
if err := json.Unmarshal(got, &gotDecoded); err != nil {
t.Fatalf("unmarshal got: %v", err)
}
want := []map[string]string{
{
"mac": "aa:bb:cc:dd:ee:ff",
"inactive-time": "10 ms",
"rx-bytes": "1234",
"tx-bytes": "5678",
"connected-time": "42 seconds",
"signal": "-40 dBm",
"rx-bitrate": "6.5 MBit/s",
"tx-bitrate": "130.0 MBit/s",
"authorized": "yes",
},
{
"mac": "11:22:33:44:55:66",
"inactive-time": "20 ms",
"rx-bytes": "9876",
"tx-bytes": "5432",
"connected-time": "84 seconds",
"signal": "-55 dBm",
"authorized": "no",
},
}
if !reflect.DeepEqual(gotDecoded, want) {
t.Fatalf("parseStationDump mismatch\n got: %#v\nwant: %#v", gotDecoded, want)
}
}
func TestParseIWInfo(t *testing.T) {
input := `Interface wlan0
ifindex: 4
wdev: 0x1
addr: 12:34:56:78:9a:bc
ssid: MyWiFi
type: managed
channel: 11 (2462 MHz), width: 20 MHz, center1: 2462 MHz
txpower: 20.00 dBm`
got := parseIWInfo(input)
var gotDecoded map[string]string
if err := json.Unmarshal(got, &gotDecoded); err != nil {
t.Fatalf("unmarshal got: %v", err)
}
want := map[string]string{
"ssid": "MyWiFi",
"type": "managed",
"channel": "11 (2462 MHz), width: 20 MHz, center1: 2462 MHz",
"tx-power": "20.00 dBm",
}
if !reflect.DeepEqual(gotDecoded, want) {
t.Fatalf("parseIWInfo mismatch\n got: %#v\nwant: %#v", gotDecoded, want)
}
}
func TestParseIWDevList(t *testing.T) {
input := `phy#0
Interface wlan0
ifindex 4
wdev 0x1
phy#1
Interface wlan1
ifindex 5
wdev 0x2`
got := parseIWDevList(input)
want := []string{"wlan0", "wlan1"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("parseIWDevList mismatch\n got: %#v\nwant: %#v", got, want)
}
}
@@ -0,0 +1,315 @@
// Package lldpmonitor manages a persistent `lldpcli -f json0 watch`
// subprocess for reactive LLDP neighbor updates. Events are framed
// as pretty-printed JSON objects separated by blank lines. Each
// event triggers full LLDP subtree regeneration.
package lldpmonitor
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
const (
reconnectInitial = 100 * time.Millisecond
reconnectMax = 30 * time.Second
reconnectFactor = 2.0
lldpMulticastMAC = "01:80:C2:00:00:0E"
treeKey = "ieee802-dot1ab-lldp:lldp"
)
// LLDPMonitor subscribes to LLDP neighbor events via a persistent
// lldpcli watch subprocess and updates the tree on every event.
type LLDPMonitor struct {
tree *tree.Tree
log *slog.Logger
}
// New creates an LLDPMonitor.
func New(t *tree.Tree, log *slog.Logger) *LLDPMonitor {
return &LLDPMonitor{tree: t, log: log}
}
// Run starts the LLDP monitor. It blocks until ctx is cancelled.
func (m *LLDPMonitor) Run(ctx context.Context) error {
delay := reconnectInitial
for {
err := m.runOnce(ctx)
if ctx.Err() != nil {
return ctx.Err()
}
m.log.Warn("lldp monitor: subprocess exited, restarting",
"err", err, "delay", delay)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
delay = time.Duration(math.Min(
float64(delay)*reconnectFactor,
float64(reconnectMax)))
}
}
func (m *LLDPMonitor) runOnce(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "lldpcli", "-f", "json0", "watch")
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start lldpcli watch: %w", err)
}
defer cmd.Wait()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1*1024*1024), 1*1024*1024)
var buf bytes.Buffer
braceDepth := 0
for scanner.Scan() {
line := scanner.Text()
// Blank-line framing: object separator.
if strings.TrimSpace(line) == "" && braceDepth == 0 {
if buf.Len() > 0 {
m.processEvent(buf.Bytes())
buf.Reset()
}
continue
}
buf.WriteString(line)
buf.WriteByte('\n')
for _, ch := range line {
switch ch {
case '{':
braceDepth++
case '}':
braceDepth--
}
}
if braceDepth == 0 && buf.Len() > 0 {
m.processEvent(buf.Bytes())
buf.Reset()
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read lldpcli: %w", err)
}
return fmt.Errorf("lldpcli watch process exited")
}
func (m *LLDPMonitor) processEvent(data []byte) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
m.log.Warn("lldp monitor: parse event", "err", err)
return
}
// Dispatch by root key: lldp-added, lldp-updated, lldp-deleted.
// All event types trigger full subtree rebuild from payload.
for key := range raw {
switch key {
case "lldp-added", "lldp-updated", "lldp-deleted":
m.rebuildTree(data)
return
}
}
m.log.Debug("lldp monitor: unknown event keys", "keys", keysOf(raw))
}
func (m *LLDPMonitor) rebuildTree(data []byte) {
result := transformLLDPEvent(data)
m.tree.Set(treeKey, result)
m.log.Debug("lldp monitor: tree updated")
}
// transformLLDPEvent converts lldpcli json0 watch output to YANG
// ieee802-dot1ab-lldp:lldp format matching the Python infix_lldp.py.
func transformLLDPEvent(data []byte) json.RawMessage {
var event map[string]json.RawMessage
if err := json.Unmarshal(data, &event); err != nil {
return json.RawMessage(`{}`)
}
// Extract the lldp payload from whichever event key is present.
var lldpPayload json.RawMessage
for _, key := range []string{"lldp-added", "lldp-updated", "lldp-deleted"} {
if p, ok := event[key]; ok {
lldpPayload = p
break
}
}
if lldpPayload == nil {
return json.RawMessage(`{}`)
}
var lldpData struct {
LLDP struct {
Interface []map[string]json.RawMessage `json:"interface"`
} `json:"lldp"`
}
if err := json.Unmarshal(lldpPayload, &lldpData); err != nil {
// Try unwrapped format.
var unwrapped struct {
Interface []map[string]json.RawMessage `json:"interface"`
}
if err := json.Unmarshal(lldpPayload, &unwrapped); err != nil {
return json.RawMessage(`{}`)
}
lldpData.LLDP.Interface = unwrapped.Interface
}
type remoteEntry struct {
TimeMark int `json:"time-mark"`
RemoteIndex int `json:"remote-index"`
ChassisIDSubtype string `json:"chassis-id-subtype"`
ChassisID string `json:"chassis-id"`
PortIDSubtype string `json:"port-id-subtype"`
PortID string `json:"port-id"`
}
type portEntry struct {
Name string `json:"name"`
DestMACAddress string `json:"dest-mac-address"`
RemoteSystems []remoteEntry `json:"remote-systems-data"`
}
portMap := make(map[string]*portEntry)
for _, ifEntry := range lldpData.LLDP.Interface {
for ifName, ifData := range ifEntry {
var iface struct {
RID interface{} `json:"rid"`
Age string `json:"age"`
Chassis struct {
ID struct {
Type string `json:"type"`
Value string `json:"value"`
} `json:"id"`
} `json:"chassis"`
Port struct {
ID struct {
Type string `json:"type"`
Value string `json:"value"`
} `json:"id"`
} `json:"port"`
}
if err := json.Unmarshal(ifData, &iface); err != nil {
continue
}
port, ok := portMap[ifName]
if !ok {
port = &portEntry{
Name: ifName,
DestMACAddress: lldpMulticastMAC,
}
portMap[ifName] = port
}
rid := 0
switch v := iface.RID.(type) {
case float64:
rid = int(v)
case string:
rid, _ = strconv.Atoi(v)
}
remote := remoteEntry{
TimeMark: parseAge(iface.Age),
RemoteIndex: rid,
ChassisIDSubtype: chassisIDSubtype(iface.Chassis.ID.Type),
ChassisID: iface.Chassis.ID.Value,
PortIDSubtype: portIDSubtype(iface.Port.ID.Type),
PortID: iface.Port.ID.Value,
}
port.RemoteSystems = append(port.RemoteSystems, remote)
}
}
var ports []portEntry
for _, p := range portMap {
if len(p.RemoteSystems) > 0 {
ports = append(ports, *p)
}
}
result := map[string]interface{}{
"ieee802-dot1ab-lldp:lldp": map[string]interface{}{
"port": ports,
},
}
out, _ := json.Marshal(result)
return json.RawMessage(out)
}
var chassisSubtypeMap = map[string]string{
"ifalias": "interface-alias",
"mac": "mac-address",
"ip": "network-address",
"ifname": "interface-name",
"local": "local",
}
var portSubtypeMap = map[string]string{
"ifalias": "interface-alias",
"mac": "mac-address",
"ip": "network-address",
"ifname": "interface-name",
"local": "local",
}
func chassisIDSubtype(t string) string {
if v, ok := chassisSubtypeMap[t]; ok {
return v
}
return "unknown"
}
func portIDSubtype(t string) string {
if v, ok := portSubtypeMap[t]; ok {
return v
}
return "unknown"
}
var ageRe = regexp.MustCompile(`(\d+)\s*day[s]*,\s*(\d+):(\d+):(\d+)`)
func parseAge(s string) int {
m := ageRe.FindStringSubmatch(s)
if m == nil {
return 0
}
days, _ := strconv.Atoi(m[1])
hours, _ := strconv.Atoi(m[2])
mins, _ := strconv.Atoi(m[3])
secs, _ := strconv.Atoi(m[4])
return days*86400 + hours*3600 + mins*60 + secs
}
func keysOf(m map[string]json.RawMessage) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
@@ -0,0 +1,216 @@
package lldpmonitor
import (
"encoding/json"
"reflect"
"testing"
)
func TestTransformLLDPEvent(t *testing.T) {
tests := []struct {
name string
input string
wantPort int
wantByIfName map[string]int
wantFirst map[string]string
}{
{
name: "lldp-added single neighbor",
input: `{
"lldp-added": {
"lldp": {
"interface": [
{
"eth0": {
"rid": 7,
"age": "0 day, 00:05:30",
"chassis": {"id": {"type": "mac", "value": "aa:bb:cc:dd:ee:ff"}},
"port": {"id": {"type": "ifname", "value": "swp1"}}
}
}
]
}
}
}`,
wantPort: 1,
wantByIfName: map[string]int{"eth0": 1},
wantFirst: map[string]string{
"chassis-id-subtype": "mac-address",
"port-id-subtype": "interface-name",
"chassis-id": "aa:bb:cc:dd:ee:ff",
"port-id": "swp1",
},
},
{
name: "lldp-updated multiple neighbors same port",
input: `{
"lldp-updated": {
"lldp": {
"interface": [
{
"eth1": {
"rid": "1",
"age": "1 day, 02:30:15",
"chassis": {"id": {"type": "ifname", "value": "leaf1"}},
"port": {"id": {"type": "local", "value": "portA"}}
}
},
{
"eth1": {
"rid": 2,
"age": "10 days, 00:00:00",
"chassis": {"id": {"type": "ip", "value": "192.0.2.1"}},
"port": {"id": {"type": "mac", "value": "00:11:22:33:44:55"}}
}
}
]
}
}
}`,
wantPort: 1,
wantByIfName: map[string]int{"eth1": 2},
},
{
name: "lldp-deleted event",
input: `{
"lldp-deleted": {
"lldp": {
"interface": [
{
"eth2": {
"rid": 4,
"age": "0 day, 00:00:01",
"chassis": {"id": {"type": "local", "value": "chassis-local"}},
"port": {"id": {"type": "ip", "value": "198.51.100.3"}}
}
}
]
}
}
}`,
wantPort: 1,
wantByIfName: map[string]int{"eth2": 1},
},
{
name: "empty object",
input: `{}`,
wantPort: 0,
wantByIfName: map[string]int{},
},
{
name: "malformed input",
input: `{not-json`,
wantPort: 0,
wantByIfName: map[string]int{},
},
}
type remote struct {
TimeMark int `json:"time-mark"`
RemoteIndex int `json:"remote-index"`
ChassisIDSubtype string `json:"chassis-id-subtype"`
ChassisID string `json:"chassis-id"`
PortIDSubtype string `json:"port-id-subtype"`
PortID string `json:"port-id"`
}
type port struct {
Name string `json:"name"`
DestMAC string `json:"dest-mac-address"`
RemoteSystems []remote `json:"remote-systems-data"`
}
type outShape struct {
LLDP struct {
Port []port `json:"port"`
} `json:"ieee802-dot1ab-lldp:lldp"`
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := transformLLDPEvent([]byte(tt.input))
var decoded outShape
if err := json.Unmarshal(got, &decoded); err != nil {
t.Fatalf("unmarshal output: %v", err)
}
if len(decoded.LLDP.Port) != tt.wantPort {
t.Fatalf("port count = %d, want %d", len(decoded.LLDP.Port), tt.wantPort)
}
byIf := make(map[string]int)
for _, p := range decoded.LLDP.Port {
if p.DestMAC != lldpMulticastMAC {
t.Fatalf("dest-mac-address = %q, want %q", p.DestMAC, lldpMulticastMAC)
}
byIf[p.Name] = len(p.RemoteSystems)
}
if !reflect.DeepEqual(byIf, tt.wantByIfName) {
t.Fatalf("neighbors by if mismatch\n got: %#v\nwant: %#v", byIf, tt.wantByIfName)
}
if len(tt.wantFirst) > 0 {
first := decoded.LLDP.Port[0].RemoteSystems[0]
gotFirst := map[string]string{
"chassis-id-subtype": first.ChassisIDSubtype,
"port-id-subtype": first.PortIDSubtype,
"chassis-id": first.ChassisID,
"port-id": first.PortID,
}
if !reflect.DeepEqual(gotFirst, tt.wantFirst) {
t.Fatalf("first remote mismatch\n got: %#v\nwant: %#v", gotFirst, tt.wantFirst)
}
}
})
}
}
func TestParseAge(t *testing.T) {
tests := []struct {
name string
in string
want int
}{
{name: "zero day", in: "0 day, 00:05:30", want: 330},
{name: "one day", in: "1 day, 02:30:15", want: 95415},
{name: "ten days plural", in: "10 days, 00:00:00", want: 864000},
{name: "empty", in: "", want: 0},
{name: "invalid", in: "n/a", want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseAge(tt.in); got != tt.want {
t.Fatalf("parseAge(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}
func TestSubtypeMappings(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "ifalias", in: "ifalias", want: "interface-alias"},
{name: "mac", in: "mac", want: "mac-address"},
{name: "ip", in: "ip", want: "network-address"},
{name: "ifname", in: "ifname", want: "interface-name"},
{name: "local", in: "local", want: "local"},
{name: "unknown", in: "foo", want: "unknown"},
}
for _, tt := range tests {
t.Run("chassis_"+tt.name, func(t *testing.T) {
if got := chassisIDSubtype(tt.in); got != tt.want {
t.Fatalf("chassisIDSubtype(%q) = %q, want %q", tt.in, got, tt.want)
}
})
t.Run("port_"+tt.name, func(t *testing.T) {
if got := portIDSubtype(tt.in); got != tt.want {
t.Fatalf("portIDSubtype(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
+690
View File
@@ -0,0 +1,690 @@
package monitor
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"sync"
"syscall"
"github.com/kernelkit/infix/src/yangerd/internal/bridgebatch"
"github.com/kernelkit/infix/src/yangerd/internal/iface"
"github.com/kernelkit/infix/src/yangerd/internal/ipbatch"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
)
// treeKey is the single YANG module key where the complete
// ietf-interfaces document is stored.
const treeKey = "ietf-interfaces:interfaces"
// NLMonitor subscribes to netlink link/address/neighbor events and
// keeps interface operational data in the in-memory tree up to date.
// It is the central coordinator for all interface data — raw ip-json
// staging data is transformed via iface.Transform() and augmented
// with ethernet/wifi/bridge data before being stored as a single
// complete YANG document.
type NLMonitor struct {
batch *ipbatch.IPBatch
brBatch *bridgebatch.BridgeBatch
tree *tree.Tree
ethRefresh func(string)
log *slog.Logger
fc iface.FileChecker
// staging holds raw ip-json data used as input to iface.Transform().
// Protected by mu.
mu sync.Mutex
links json.RawMessage // ip -json -s -d link show (includes stats+details)
addrs json.RawMessage // ip -json addr show
fdb map[string]json.RawMessage
mdb map[string]json.RawMessage
ethernet map[string]json.RawMessage // ifname → ethtool JSON
wifi map[string]json.RawMessage // ifname → wifi JSON
lastOperStatus map[string]string
}
// New creates a netlink monitor backed by ip/bridge batch query workers.
func New(batch *ipbatch.IPBatch, brBatch *bridgebatch.BridgeBatch, t *tree.Tree, fc iface.FileChecker, log *slog.Logger) *NLMonitor {
return &NLMonitor{
batch: batch,
brBatch: brBatch,
tree: t,
fc: fc,
log: log,
fdb: make(map[string]json.RawMessage),
mdb: make(map[string]json.RawMessage),
ethernet: make(map[string]json.RawMessage),
wifi: make(map[string]json.RawMessage),
lastOperStatus: make(map[string]string),
}
}
// SetEthRefresh sets an optional callback used to refresh ethtool data
// when interface link events are received.
func (m *NLMonitor) SetEthRefresh(fn func(string)) {
m.ethRefresh = fn
}
// SetEthernetData updates the staged ethernet data for an interface
// and triggers a full rebuild of the YANG document.
func (m *NLMonitor) SetEthernetData(ifname string, data json.RawMessage) {
m.mu.Lock()
m.ethernet[ifname] = data
m.mu.Unlock()
m.rebuild()
}
// SetWifiData updates the staged wifi data for an interface
// and triggers a full rebuild of the YANG document.
func (m *NLMonitor) SetWifiData(ifname string, data json.RawMessage) {
m.mu.Lock()
m.wifi[ifname] = data
m.mu.Unlock()
m.rebuild()
}
// Run starts the netlink monitor loop and returns on context cancellation,
// channel closure, or subscription errors.
func (m *NLMonitor) Run(ctx context.Context) error {
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
done := make(chan struct{})
defer close(done)
errorCallback := func(err error) {
if err == nil {
return
}
m.log.Error("netlink subscription error", "err", err)
cancel()
}
linkCh := make(chan netlink.LinkUpdate, 64)
addrCh := make(chan netlink.AddrUpdate, 64)
neighCh := make(chan netlink.NeighUpdate, 64)
mdbCh := make(chan struct{}, 32)
if err := netlink.LinkSubscribeWithOptions(linkCh, done, netlink.LinkSubscribeOptions{
ErrorCallback: errorCallback,
}); err != nil {
return fmt.Errorf("subscribe link updates: %w", err)
}
if err := netlink.AddrSubscribeWithOptions(addrCh, done, netlink.AddrSubscribeOptions{
ErrorCallback: errorCallback,
}); err != nil {
return fmt.Errorf("subscribe addr updates: %w", err)
}
if err := netlink.NeighSubscribeWithOptions(neighCh, done, netlink.NeighSubscribeOptions{
ErrorCallback: errorCallback,
}); err != nil {
return fmt.Errorf("subscribe neigh updates: %w", err)
}
if err := m.subscribeBridgeMDB(runCtx, mdbCh, errorCallback); err != nil {
return fmt.Errorf("subscribe bridge mdb updates: %w", err)
}
if err := m.initialDump(); err != nil {
m.log.Error("initial dump failed", "err", err)
}
for {
select {
case <-runCtx.Done():
if ctx.Err() != nil {
return ctx.Err()
}
return runCtx.Err()
case lu, ok := <-linkCh:
if !ok {
return fmt.Errorf("link update channel closed")
}
m.handleLinkUpdate(lu)
case au, ok := <-addrCh:
if !ok {
return fmt.Errorf("addr update channel closed")
}
m.handleAddrUpdate(au)
case nu, ok := <-neighCh:
if !ok {
return fmt.Errorf("neigh update channel closed")
}
m.handleNeighUpdate(nu)
case _, ok := <-mdbCh:
if !ok {
return fmt.Errorf("bridge mdb update channel closed")
}
m.handleMDBUpdate()
}
}
}
func (m *NLMonitor) initialDump() error {
linkRaw, err := m.queryIP("link show")
if err != nil {
return err
}
addrRaw, err := m.queryIP("addr show")
if err != nil {
return err
}
m.mu.Lock()
m.links = linkRaw
m.addrs = addrRaw
for _, name := range interfaceNames(linkRaw) {
if st, ok := extractOperStatus(filterByIfName(linkRaw, name)); ok {
m.lastOperStatus[name] = st
}
}
m.mu.Unlock()
m.rebuild()
return nil
}
func (m *NLMonitor) handleLinkUpdate(update netlink.LinkUpdate) {
name, ok := linkNameFromUpdate(update)
if !ok || name == "" {
m.log.Warn("link update without interface name", "index", int(update.Index))
return
}
m.refreshInterface(name)
if m.ethRefresh != nil {
m.ethRefresh(name)
}
}
func (m *NLMonitor) handleAddrUpdate(update netlink.AddrUpdate) {
ifname, err := ifNameByIndex(update.LinkIndex)
if err != nil {
m.log.Warn("addr update: resolve interface", "index", update.LinkIndex, "err", err)
return
}
raw, err := m.queryIP("addr show dev " + ifname)
if err != nil {
return
}
m.mu.Lock()
m.addrs = replaceByIfName(m.addrs, ifname, raw)
m.mu.Unlock()
m.rebuild()
}
func (m *NLMonitor) handleNeighUpdate(update netlink.NeighUpdate) {
if isBridgeFDB(update) {
bridgeName, ok := bridgeNameFromNeigh(update)
if !ok {
m.log.Warn("fdb update: bridge name not found", "link-index", update.LinkIndex)
return
}
raw, err := m.queryBridge("fdb show br " + bridgeName)
if err != nil {
return
}
m.mu.Lock()
m.fdb[bridgeName] = raw
m.mu.Unlock()
m.rebuild()
return
}
}
func (m *NLMonitor) handleMDBUpdate() {
raw, err := m.queryBridge("mdb show")
if err != nil {
return
}
m.mu.Lock()
for _, bridgeName := range bridgeNames(raw) {
m.mdb[bridgeName] = filterByBridge(raw, bridgeName)
}
m.mu.Unlock()
m.rebuild()
}
func (m *NLMonitor) refreshInterface(name string) {
linkRaw, err := m.queryIP("link show dev " + name)
if err != nil {
return
}
addrRaw, err := m.queryIP("addr show dev " + name)
if err != nil {
addrRaw = nil
}
m.mu.Lock()
m.updateOperStatus(name, linkRaw)
m.links = replaceByIfName(m.links, name, linkRaw)
if addrRaw != nil {
m.addrs = replaceByIfName(m.addrs, name, addrRaw)
}
m.mu.Unlock()
m.rebuild()
}
// rebuild runs iface.Transform on all staged data, merges augments
// (ethernet, wifi, bridge fdb/mdb), and stores the result.
// Caller must NOT hold m.mu.
func (m *NLMonitor) rebuild() {
m.mu.Lock()
doc := iface.Transform(m.links, m.addrs, m.links, m.fc)
eth := copyStringMap(m.ethernet)
wfi := copyStringMap(m.wifi)
fdb := copyStringMap(m.fdb)
mdb := copyStringMap(m.mdb)
m.mu.Unlock()
doc = mergeAugments(doc, eth, wfi, fdb, mdb)
m.tree.Set(treeKey, doc)
}
// mergeAugments adds ethernet, wifi, and bridge data into the
// complete ietf-interfaces document produced by iface.Transform().
func mergeAugments(doc json.RawMessage, ethernet, wifi, fdb, mdb map[string]json.RawMessage) json.RawMessage {
if len(ethernet) == 0 && len(wifi) == 0 && len(fdb) == 0 && len(mdb) == 0 {
return doc
}
var root map[string]any
if err := json.Unmarshal(doc, &root); err != nil {
return doc
}
ifaceList, ok := root["interface"]
if !ok {
return doc
}
ifaceArr, ok := ifaceList.([]any)
if !ok {
return doc
}
for i, entry := range ifaceArr {
ifaceObj, ok := entry.(map[string]any)
if !ok {
continue
}
name, _ := ifaceObj["name"].(string)
if name == "" {
continue
}
if ethData, ok := ethernet[name]; ok {
var ethObj any
if err := json.Unmarshal(ethData, &ethObj); err == nil {
ifaceObj["ieee802-ethernet-interface:ethernet"] = ethObj
}
}
if wifiData, ok := wifi[name]; ok {
var wifiObj any
if err := json.Unmarshal(wifiData, &wifiObj); err == nil {
ifaceObj["infix-interfaces:wifi"] = wifiObj
}
}
if fdbData, ok := fdb[name]; ok {
bridgeObj := ensureBridgeAugment(ifaceObj)
var fdbObj any
if err := json.Unmarshal(fdbData, &fdbObj); err == nil {
bridgeObj["fdb"] = fdbObj
}
}
if mdbData, ok := mdb[name]; ok {
bridgeObj := ensureBridgeAugment(ifaceObj)
var mdbObj any
if err := json.Unmarshal(mdbData, &mdbObj); err == nil {
bridgeObj["mdb"] = mdbObj
}
}
ifaceArr[i] = ifaceObj
}
out, err := json.Marshal(root)
if err != nil {
return doc
}
return json.RawMessage(out)
}
// ensureBridgeAugment returns the bridge augment object within an
// interface, creating it if necessary.
func ensureBridgeAugment(ifaceObj map[string]any) map[string]any {
key := "infix-interfaces:bridge"
if existing, ok := ifaceObj[key]; ok {
if m, ok := existing.(map[string]any); ok {
return m
}
}
bridgeObj := map[string]any{}
ifaceObj[key] = bridgeObj
return bridgeObj
}
func copyStringMap(m map[string]json.RawMessage) map[string]json.RawMessage {
if len(m) == 0 {
return nil
}
cp := make(map[string]json.RawMessage, len(m))
for k, v := range m {
cp[k] = v
}
return cp
}
func (m *NLMonitor) updateOperStatus(ifname string, raw json.RawMessage) {
status, ok := extractOperStatus(raw)
if !ok {
return
}
prev, had := m.lastOperStatus[ifname]
m.lastOperStatus[ifname] = status
if had && prev != status {
m.log.Info("oper-status transition", "ifname", ifname, "from", prev, "to", status)
}
}
func (m *NLMonitor) queryIP(command string) (json.RawMessage, error) {
raw, err := m.batch.Query(command)
if err != nil {
if errors.Is(err, ipbatch.ErrBatchDead) {
m.log.Warn("ip batch dead", "command", command, "err", err)
return nil, err
}
m.log.Error("ip batch query failed", "command", command, "err", err)
return nil, err
}
return raw, nil
}
func (m *NLMonitor) queryBridge(command string) (json.RawMessage, error) {
raw, err := m.brBatch.Query(command)
if err != nil {
if errors.Is(err, bridgebatch.ErrBatchDead) {
m.log.Warn("bridge batch dead", "command", command, "err", err)
return nil, err
}
m.log.Error("bridge batch query failed", "command", command, "err", err)
return nil, err
}
return raw, nil
}
func (m *NLMonitor) subscribeBridgeMDB(ctx context.Context, ch chan<- struct{}, errorCallback func(error)) error {
sock, err := nl.Subscribe(syscall.NETLINK_ROUTE, 26)
if err != nil {
return err
}
go func() {
defer close(ch)
defer sock.Close()
for {
select {
case <-ctx.Done():
return
default:
}
msgs, _, err := sock.Receive()
if err != nil {
if ctx.Err() != nil {
return
}
errorCallback(err)
return
}
if len(msgs) == 0 {
continue
}
select {
case ch <- struct{}{}:
default:
}
}
}()
return nil
}
func ifNameByIndex(index int) (string, error) {
iface, err := net.InterfaceByIndex(index)
if err != nil {
return "", err
}
return iface.Name, nil
}
func linkNameFromUpdate(update netlink.LinkUpdate) (string, bool) {
if update.Link != nil && update.Link.Attrs() != nil && update.Link.Attrs().Name != "" {
return update.Link.Attrs().Name, true
}
if update.Index <= 0 {
return "", false
}
name, err := ifNameByIndex(int(update.Index))
if err != nil {
return "", false
}
return name, true
}
func isBridgeFDB(update netlink.NeighUpdate) bool {
if update.Family == syscall.AF_BRIDGE {
return true
}
if update.MasterIndex > 0 {
return true
}
if update.Flags&netlink.NTF_MASTER != 0 {
return true
}
return false
}
func bridgeNameFromNeigh(update netlink.NeighUpdate) (string, bool) {
if update.MasterIndex > 0 {
name, err := ifNameByIndex(update.MasterIndex)
if err == nil {
return name, true
}
}
if update.LinkIndex <= 0 {
return "", false
}
link, err := netlink.LinkByIndex(update.LinkIndex)
if err == nil && link != nil && link.Attrs() != nil && link.Attrs().MasterIndex > 0 {
name, err := ifNameByIndex(link.Attrs().MasterIndex)
if err == nil {
return name, true
}
}
return "", false
}
func extractOperStatus(raw json.RawMessage) (string, bool) {
var rows []map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil || len(rows) == 0 {
return "", false
}
stateRaw, ok := rows[0]["operstate"]
if !ok {
return "", false
}
var state string
if err := json.Unmarshal(stateRaw, &state); err != nil || state == "" {
return "", false
}
return state, true
}
func interfaceNames(raw json.RawMessage) []string {
var rows []map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
return nil
}
names := make([]string, 0, len(rows))
seen := make(map[string]struct{}, len(rows))
for _, row := range rows {
ifnRaw, ok := row["ifname"]
if !ok {
continue
}
var ifname string
if err := json.Unmarshal(ifnRaw, &ifname); err != nil || ifname == "" {
continue
}
if _, ok := seen[ifname]; ok {
continue
}
seen[ifname] = struct{}{}
names = append(names, ifname)
}
return names
}
func filterByIfName(raw json.RawMessage, ifname string) json.RawMessage {
var rows []map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
return json.RawMessage(`[]`)
}
filtered := make([]map[string]json.RawMessage, 0, 1)
for _, row := range rows {
ifnRaw, ok := row["ifname"]
if !ok {
continue
}
var name string
if err := json.Unmarshal(ifnRaw, &name); err != nil {
continue
}
if name == ifname {
filtered = append(filtered, row)
}
}
out, err := json.Marshal(filtered)
if err != nil {
return json.RawMessage(`[]`)
}
return json.RawMessage(out)
}
// replaceByIfName replaces all entries for ifname in the bulk array
// with entries from perIface, and returns the updated full array.
func replaceByIfName(bulk json.RawMessage, ifname string, perIface json.RawMessage) json.RawMessage {
var bulkRows []json.RawMessage
if err := json.Unmarshal(bulk, &bulkRows); err != nil {
return perIface
}
kept := make([]json.RawMessage, 0, len(bulkRows))
for _, row := range bulkRows {
var obj map[string]json.RawMessage
if err := json.Unmarshal(row, &obj); err != nil {
kept = append(kept, row)
continue
}
ifnRaw, ok := obj["ifname"]
if !ok {
kept = append(kept, row)
continue
}
var name string
if err := json.Unmarshal(ifnRaw, &name); err != nil || name != ifname {
kept = append(kept, row)
}
}
var newRows []json.RawMessage
if err := json.Unmarshal(perIface, &newRows); err == nil {
kept = append(kept, newRows...)
}
out, err := json.Marshal(kept)
if err != nil {
return bulk
}
return json.RawMessage(out)
}
func bridgeNames(raw json.RawMessage) []string {
var rows []map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
return nil
}
names := make([]string, 0, len(rows))
seen := make(map[string]struct{}, len(rows))
for _, row := range rows {
brRaw, ok := row["br"]
if !ok {
continue
}
var name string
if err := json.Unmarshal(brRaw, &name); err != nil || name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
return names
}
func filterByBridge(raw json.RawMessage, bridgeName string) json.RawMessage {
var rows []map[string]json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
return json.RawMessage(`[]`)
}
filtered := make([]map[string]json.RawMessage, 0, 1)
for _, row := range rows {
brRaw, ok := row["br"]
if !ok {
continue
}
var br string
if err := json.Unmarshal(brRaw, &br); err != nil {
continue
}
if br == bridgeName {
filtered = append(filtered, row)
}
}
out, err := json.Marshal(filtered)
if err != nil {
return json.RawMessage(`[]`)
}
return json.RawMessage(out)
}
@@ -0,0 +1,392 @@
package monitor
import (
"encoding/json"
"reflect"
"syscall"
"testing"
"github.com/vishvananda/netlink"
)
func TestExtractOperStatus(t *testing.T) {
tests := []struct {
name string
raw json.RawMessage
want string
wantOK bool
}{
{
name: "valid single entry",
raw: json.RawMessage(`[{"operstate":"UP"}]`),
want: "UP",
wantOK: true,
},
{
name: "multiple entries first wins",
raw: json.RawMessage(`[{"operstate":"DOWN"},{"operstate":"UP"}]`),
want: "DOWN",
wantOK: true,
},
{
name: "missing operstate",
raw: json.RawMessage(`[{"ifname":"eth0"}]`),
want: "",
wantOK: false,
},
{
name: "empty array",
raw: json.RawMessage(`[]`),
want: "",
wantOK: false,
},
{
name: "invalid json",
raw: json.RawMessage(`{`),
want: "",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := extractOperStatus(tt.raw)
if got != tt.want || ok != tt.wantOK {
t.Fatalf("extractOperStatus() = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.wantOK)
}
})
}
}
func TestInterfaceNames(t *testing.T) {
tests := []struct {
name string
raw json.RawMessage
want []string
}{
{
name: "deduplicate and keep order",
raw: json.RawMessage(`[
{"ifname":"eth0"},
{"ifname":"eth1"},
{"ifname":"eth0"},
{"x":"y"}
]`),
want: []string{"eth0", "eth1"},
},
{
name: "empty array",
raw: json.RawMessage(`[]`),
want: []string{},
},
{
name: "objects without ifname skipped",
raw: json.RawMessage(`[{"name":"eth0"},{"foo":"bar"}]`),
want: []string{},
},
{
name: "invalid json",
raw: json.RawMessage(`{`),
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := interfaceNames(tt.raw)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("interfaceNames() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestFilterByIfName(t *testing.T) {
tests := []struct {
name string
raw json.RawMessage
ifname string
wantCount int
wantEmpty bool
}{
{
name: "filters correctly",
raw: json.RawMessage(`[
{"ifname":"eth0","x":1},
{"ifname":"eth1","x":2},
{"ifname":"eth0","x":3}
]`),
ifname: "eth0",
wantCount: 2,
},
{
name: "no matches returns empty array",
raw: json.RawMessage(`[{"ifname":"eth1"}]`),
ifname: "eth0",
wantCount: 0,
wantEmpty: true,
},
{
name: "invalid json",
raw: json.RawMessage(`{`),
ifname: "eth0",
wantCount: 0,
wantEmpty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := filterByIfName(tt.raw, tt.ifname)
var rows []map[string]json.RawMessage
if err := json.Unmarshal(got, &rows); err != nil {
t.Fatalf("unmarshal filtered rows: %v", err)
}
if len(rows) != tt.wantCount {
t.Fatalf("row count = %d, want %d", len(rows), tt.wantCount)
}
if tt.wantEmpty && string(got) != "[]" {
t.Fatalf("expected [] got %s", string(got))
}
})
}
}
func TestBridgeNames(t *testing.T) {
raw := json.RawMessage(`[
{"br":"br0"},
{"br":"br1"},
{"br":"br0"},
{"ifname":"eth0"}
]`)
got := bridgeNames(raw)
want := []string{"br0", "br1"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("bridgeNames() = %#v, want %#v", got, want)
}
}
func TestFilterByBridge(t *testing.T) {
tests := []struct {
name string
raw json.RawMessage
bridge string
wantCount int
wantEmpty bool
}{
{
name: "filters correctly",
raw: json.RawMessage(`[
{"br":"br0","grp":"239.1.1.1"},
{"br":"br1","grp":"239.1.1.2"},
{"br":"br0","grp":"239.1.1.3"}
]`),
bridge: "br0",
wantCount: 2,
},
{
name: "no matches returns empty array",
raw: json.RawMessage(`[{"br":"br1"}]`),
bridge: "br0",
wantCount: 0,
wantEmpty: true,
},
{
name: "invalid json",
raw: json.RawMessage(`{`),
bridge: "br0",
wantCount: 0,
wantEmpty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := filterByBridge(tt.raw, tt.bridge)
var rows []map[string]json.RawMessage
if err := json.Unmarshal(got, &rows); err != nil {
t.Fatalf("unmarshal filtered rows: %v", err)
}
if len(rows) != tt.wantCount {
t.Fatalf("row count = %d, want %d", len(rows), tt.wantCount)
}
if tt.wantEmpty && string(got) != "[]" {
t.Fatalf("expected [] got %s", string(got))
}
})
}
}
func TestIsBridgeFDB(t *testing.T) {
tests := []struct {
name string
update netlink.NeighUpdate
want bool
}{
{
name: "bridge family",
update: netlink.NeighUpdate{Neigh: netlink.Neigh{Family: syscall.AF_BRIDGE}},
want: true,
},
{
name: "master index set",
update: netlink.NeighUpdate{Neigh: netlink.Neigh{MasterIndex: 10}},
want: true,
},
{
name: "master flag set",
update: netlink.NeighUpdate{Neigh: netlink.Neigh{Flags: netlink.NTF_MASTER}},
want: true,
},
{
name: "non-bridge",
update: netlink.NeighUpdate{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isBridgeFDB(tt.update); got != tt.want {
t.Fatalf("isBridgeFDB() = %v, want %v", got, tt.want)
}
})
}
}
func TestReplaceByIfName(t *testing.T) {
tests := []struct {
name string
bulk json.RawMessage
ifname string
perIface json.RawMessage
want int
}{
{
name: "replace existing",
bulk: json.RawMessage(`[{"ifname":"eth0","x":1},{"ifname":"eth1","x":2}]`),
ifname: "eth0",
perIface: json.RawMessage(`[{"ifname":"eth0","x":99}]`),
want: 2,
},
{
name: "add new",
bulk: json.RawMessage(`[{"ifname":"eth0","x":1}]`),
ifname: "eth1",
perIface: json.RawMessage(`[{"ifname":"eth1","x":2}]`),
want: 2,
},
{
name: "empty bulk",
bulk: json.RawMessage(`[]`),
ifname: "eth0",
perIface: json.RawMessage(`[{"ifname":"eth0","x":1}]`),
want: 1,
},
{
name: "invalid bulk",
bulk: json.RawMessage(`{`),
ifname: "eth0",
perIface: json.RawMessage(`[{"ifname":"eth0","x":1}]`),
want: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := replaceByIfName(tt.bulk, tt.ifname, tt.perIface)
var rows []json.RawMessage
if err := json.Unmarshal(got, &rows); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(rows) != tt.want {
t.Fatalf("row count = %d, want %d (raw: %s)", len(rows), tt.want, string(got))
}
})
}
}
func TestReplaceByIfNamePreservesUpdatedData(t *testing.T) {
bulk := json.RawMessage(`[{"ifname":"eth0","x":1},{"ifname":"eth1","x":2}]`)
updated := replaceByIfName(bulk, "eth0", json.RawMessage(`[{"ifname":"eth0","x":99}]`))
var rows []map[string]json.RawMessage
if err := json.Unmarshal(updated, &rows); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, row := range rows {
var name string
json.Unmarshal(row["ifname"], &name)
if name == "eth0" {
var x int
json.Unmarshal(row["x"], &x)
if x != 99 {
t.Fatalf("eth0.x = %d, want 99", x)
}
return
}
}
t.Fatal("eth0 not found in result")
}
func TestMergeAugments(t *testing.T) {
doc := json.RawMessage(`{"interface":[{"name":"eth0","type":"infix-if-type:ethernet"},{"name":"br0","type":"infix-if-type:bridge"}]}`)
eth := map[string]json.RawMessage{
"eth0": json.RawMessage(`{"speed":1000,"duplex":"full"}`),
}
fdb := map[string]json.RawMessage{
"br0": json.RawMessage(`[{"mac":"00:11:22:33:44:55"}]`),
}
got := mergeAugments(doc, eth, nil, fdb, nil)
var root map[string]any
if err := json.Unmarshal(got, &root); err != nil {
t.Fatalf("unmarshal: %v", err)
}
ifaces := root["interface"].([]any)
eth0 := ifaces[0].(map[string]any)
if _, ok := eth0["ieee802-ethernet-interface:ethernet"]; !ok {
t.Fatal("ethernet augment not merged into eth0")
}
br0 := ifaces[1].(map[string]any)
bridge, ok := br0["infix-interfaces:bridge"]
if !ok {
t.Fatal("bridge augment not created for br0")
}
bridgeMap := bridge.(map[string]any)
if _, ok := bridgeMap["fdb"]; !ok {
t.Fatal("fdb not merged into bridge augment")
}
}
func TestMergeAugmentsNoOp(t *testing.T) {
doc := json.RawMessage(`{"interface":[{"name":"lo"}]}`)
got := mergeAugments(doc, nil, nil, nil, nil)
if string(got) != string(doc) {
t.Fatalf("expected no-op, got %s", string(got))
}
}
func TestMergeAugmentsInvalidDoc(t *testing.T) {
doc := json.RawMessage(`{invalid`)
eth := map[string]json.RawMessage{"eth0": json.RawMessage(`{}`)}
got := mergeAugments(doc, eth, nil, nil, nil)
if string(got) != string(doc) {
t.Fatalf("expected passthrough on invalid doc, got %s", string(got))
}
}
func TestTreeKey(t *testing.T) {
if treeKey != "ietf-interfaces:interfaces" {
t.Fatalf("treeKey = %q, want %q", treeKey, "ietf-interfaces:interfaces")
}
}
+49
View File
@@ -0,0 +1,49 @@
package testutil
import (
"context"
"fmt"
)
// MockRunner records command invocations and returns pre-configured output.
type MockRunner struct {
Results map[string][]byte
Errors map[string]error
}
// Run returns the pre-configured result for the command name.
func (m *MockRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
key := name
for _, a := range args {
key += " " + a
}
if err, ok := m.Errors[key]; ok {
return nil, err
}
if data, ok := m.Results[key]; ok {
return data, nil
}
return nil, fmt.Errorf("mock: no result for %q", key)
}
// MockFileReader returns pre-configured file contents.
type MockFileReader struct {
Files map[string][]byte
Globs map[string][]string
}
// ReadFile returns pre-configured data for the path.
func (m *MockFileReader) ReadFile(path string) ([]byte, error) {
if data, ok := m.Files[path]; ok {
return data, nil
}
return nil, fmt.Errorf("mock: file not found: %s", path)
}
// Glob returns pre-configured matches for the pattern.
func (m *MockFileReader) Glob(pattern string) ([]string, error) {
if matches, ok := m.Globs[pattern]; ok {
return matches, nil
}
return nil, nil
}
+243
View File
@@ -0,0 +1,243 @@
// Package tree provides a concurrent in-memory store for per-module
// YANG operational data, keyed by module-qualified names like
// "ietf-system:system-state".
//
// Each module has its own read-write mutex, so writers for different
// modules never block each other. All methods are safe for concurrent
// use.
package tree
import (
"encoding/json"
"sync"
"time"
)
// OnDemandFunc returns a JSON blob computed at call time.
// Registered providers are invoked on every Get/GetMulti to supply
// fields that must always be fresh (e.g. uptime, current-datetime).
type OnDemandFunc func() json.RawMessage
// modelEntry holds a single YANG module's pre-serialized JSON blob
// and its own read-write mutex.
type modelEntry struct {
mu sync.RWMutex
data json.RawMessage
updated time.Time
}
// Tree holds the operational YANG data in per-module JSON blobs.
type Tree struct {
mu sync.RWMutex // protects the models map itself
models map[string]*modelEntry
providers map[string]OnDemandFunc // on-demand overlay providers
}
// New creates an empty Tree.
func New() *Tree {
return &Tree{
models: make(map[string]*modelEntry),
providers: make(map[string]OnDemandFunc),
}
}
// RegisterProvider adds an on-demand overlay for the given key.
// When Get or GetMulti reads this key the provider is called and its
// result is shallow-merged on top of the cached data. The cached
// entry is never mutated — a merged copy is returned.
func (t *Tree) RegisterProvider(key string, fn OnDemandFunc) {
t.mu.Lock()
t.providers[key] = fn
t.mu.Unlock()
}
// Set replaces the entire subtree at the given YANG module key.
// Only the target module's write lock is held; other modules remain
// readable and writable.
func (t *Tree) Set(key string, v json.RawMessage) {
t.mu.RLock()
entry, ok := t.models[key]
t.mu.RUnlock()
if !ok {
t.mu.Lock()
entry, ok = t.models[key]
if !ok {
entry = &modelEntry{}
t.models[key] = entry
}
t.mu.Unlock()
}
entry.mu.Lock()
entry.data = v
entry.updated = time.Now()
entry.mu.Unlock()
}
// Get returns the raw JSON for the given module key.
// If a provider is registered for key its output is shallow-merged on
// top of the cached data without mutating the cache.
func (t *Tree) Get(key string) json.RawMessage {
t.mu.RLock()
entry, ok := t.models[key]
provider := t.providers[key]
t.mu.RUnlock()
if !ok {
return nil
}
entry.mu.RLock()
data := entry.data
entry.mu.RUnlock()
if provider == nil {
return data
}
return shallowMerge(data, provider())
}
// GetMulti returns the raw JSON for multiple module keys.
// Each module's read lock is acquired and released individually —
// the result is eventually consistent, not a snapshot.
// Providers are applied per-key, same as Get.
func (t *Tree) GetMulti(keys []string) []json.RawMessage {
result := make([]json.RawMessage, 0, len(keys))
t.mu.RLock()
defer t.mu.RUnlock()
for _, key := range keys {
entry, ok := t.models[key]
if !ok {
continue
}
entry.mu.RLock()
data := entry.data
entry.mu.RUnlock()
if provider, has := t.providers[key]; has {
data = shallowMerge(data, provider())
}
result = append(result, data)
}
return result
}
// Keys returns all registered module keys.
func (t *Tree) Keys() []string {
t.mu.RLock()
defer t.mu.RUnlock()
keys := make([]string, 0, len(t.models))
for k := range t.models {
keys = append(keys, k)
}
return keys
}
// ModelInfo holds metadata for a single model key.
type ModelInfo struct {
LastUpdated time.Time
SizeBytes int
}
// Merge performs a shallow first-level JSON merge of partial into
// the existing blob at key. If the key does not exist yet, partial
// becomes the entire value. Each top-level field in partial
// overwrites the corresponding field in the existing object; fields
// not mentioned in partial are preserved.
//
// Both the existing data and partial must be JSON objects (maps).
// If either is not a valid JSON object, Merge falls back to Set.
func (t *Tree) Merge(key string, partial json.RawMessage) {
t.mu.RLock()
entry, ok := t.models[key]
t.mu.RUnlock()
if !ok {
// No existing entry — just set.
t.Set(key, partial)
return
}
entry.mu.Lock()
defer entry.mu.Unlock()
// Unmarshal existing data.
var base map[string]json.RawMessage
if len(entry.data) == 0 || json.Unmarshal(entry.data, &base) != nil {
base = make(map[string]json.RawMessage)
}
// Unmarshal partial.
var overlay map[string]json.RawMessage
if json.Unmarshal(partial, &overlay) != nil {
// partial is not a JSON object — fall back to full replace.
entry.data = partial
entry.updated = time.Now()
return
}
for k, v := range overlay {
base[k] = v
}
merged, err := json.Marshal(base)
if err != nil {
// Should never happen with valid JSON inputs.
entry.data = partial
entry.updated = time.Now()
return
}
entry.data = merged
entry.updated = time.Now()
}
// Delete removes a key from the tree entirely.
func (t *Tree) Delete(key string) {
t.mu.Lock()
delete(t.models, key)
t.mu.Unlock()
}
// shallowMerge overlays the top-level fields of overlay onto base and
// returns a new JSON blob. Neither input is modified. If either
// is not a valid JSON object the overlay wins outright.
func shallowMerge(base, overlay json.RawMessage) json.RawMessage {
if len(overlay) == 0 {
return base
}
if len(base) == 0 {
return overlay
}
var bm map[string]json.RawMessage
if json.Unmarshal(base, &bm) != nil {
return overlay
}
var om map[string]json.RawMessage
if json.Unmarshal(overlay, &om) != nil {
return overlay
}
for k, v := range om {
bm[k] = v
}
merged, err := json.Marshal(bm)
if err != nil {
return overlay
}
return merged
}
// Info returns metadata for the given module key.
func (t *Tree) Info(key string) (ModelInfo, bool) {
t.mu.RLock()
entry, ok := t.models[key]
t.mu.RUnlock()
if !ok {
return ModelInfo{}, false
}
entry.mu.RLock()
defer entry.mu.RUnlock()
return ModelInfo{
LastUpdated: entry.updated,
SizeBytes: len(entry.data),
}, true
}
+325
View File
@@ -0,0 +1,325 @@
package tree
import (
"encoding/json"
"sync"
"testing"
)
func TestSetGet(t *testing.T) {
tr := New()
tr.Set("ietf-system:system", json.RawMessage(`{"hostname":"r1"}`))
got := tr.Get("ietf-system:system")
if string(got) != `{"hostname":"r1"}` {
t.Fatalf("unexpected: %s", got)
}
}
func TestGetMissing(t *testing.T) {
tr := New()
if got := tr.Get("nonexistent"); got != nil {
t.Fatalf("expected nil, got: %s", got)
}
}
func TestSetOverwrite(t *testing.T) {
tr := New()
tr.Set("key", json.RawMessage(`"v1"`))
tr.Set("key", json.RawMessage(`"v2"`))
if got := tr.Get("key"); string(got) != `"v2"` {
t.Fatalf("expected v2, got: %s", got)
}
}
func TestGetMulti(t *testing.T) {
tr := New()
tr.Set("a", json.RawMessage(`1`))
tr.Set("b", json.RawMessage(`2`))
tr.Set("c", json.RawMessage(`3`))
results := tr.GetMulti([]string{"a", "c"})
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if string(results[0]) != "1" || string(results[1]) != "3" {
t.Fatalf("unexpected results: %s, %s", results[0], results[1])
}
}
func TestGetMultiMissing(t *testing.T) {
tr := New()
tr.Set("a", json.RawMessage(`1`))
results := tr.GetMulti([]string{"a", "missing"})
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
}
func TestKeys(t *testing.T) {
tr := New()
tr.Set("x", json.RawMessage(`1`))
tr.Set("y", json.RawMessage(`2`))
keys := tr.Keys()
if len(keys) != 2 {
t.Fatalf("expected 2 keys, got %d", len(keys))
}
}
func TestInfo(t *testing.T) {
tr := New()
tr.Set("k", json.RawMessage(`{"data":true}`))
info, ok := tr.Info("k")
if !ok {
t.Fatal("expected ok")
}
if info.SizeBytes != len(`{"data":true}`) {
t.Fatalf("expected size %d, got %d", len(`{"data":true}`), info.SizeBytes)
}
if info.LastUpdated.IsZero() {
t.Fatal("expected non-zero LastUpdated")
}
}
func TestInfoMissing(t *testing.T) {
tr := New()
_, ok := tr.Info("missing")
if ok {
t.Fatal("expected !ok for missing key")
}
}
func TestConcurrentSetGet(t *testing.T) {
tr := New()
var wg sync.WaitGroup
const N = 100
for i := 0; i < N; i++ {
wg.Add(2)
go func(i int) {
defer wg.Done()
tr.Set("shared", json.RawMessage(`{"i":`+string(rune('0'+i%10))+`}`))
}(i)
go func() {
defer wg.Done()
tr.Get("shared")
}()
}
wg.Wait()
if got := tr.Get("shared"); got == nil {
t.Fatal("expected non-nil after concurrent writes")
}
}
func TestMerge(t *testing.T) {
tests := []struct {
name string
existing string
partial string
want map[string]json.RawMessage
}{
{
name: "merge into existing preserves old fields",
existing: `{"a":"1","b":"2"}`,
partial: `{"c":"3"}`,
want: map[string]json.RawMessage{
"a": json.RawMessage(`"1"`),
"b": json.RawMessage(`"2"`),
"c": json.RawMessage(`"3"`),
},
},
{
name: "merge overwrites overlapping field",
existing: `{"a":"old","b":"keep"}`,
partial: `{"a":"new"}`,
want: map[string]json.RawMessage{
"a": json.RawMessage(`"new"`),
"b": json.RawMessage(`"keep"`),
},
},
{
name: "merge with complex nested values",
existing: `{"protocols":{"ospf":true}}`,
partial: `{"ribs":{"rib":[{"name":"ipv4"}]}}`,
want: map[string]json.RawMessage{
"protocols": json.RawMessage(`{"ospf":true}`),
"ribs": json.RawMessage(`{"rib":[{"name":"ipv4"}]}`),
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tr := New()
tr.Set("k", json.RawMessage(tc.existing))
tr.Merge("k", json.RawMessage(tc.partial))
var got map[string]json.RawMessage
if err := json.Unmarshal(tr.Get("k"), &got); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
for field, wantVal := range tc.want {
gotVal, ok := got[field]
if !ok {
t.Fatalf("missing field %q", field)
}
if string(gotVal) != string(wantVal) {
t.Fatalf("field %q: got %s, want %s", field, gotVal, wantVal)
}
}
if len(got) != len(tc.want) {
t.Fatalf("got %d fields, want %d", len(got), len(tc.want))
}
})
}
}
func TestMergeIntoEmpty(t *testing.T) {
tr := New()
tr.Merge("new-key", json.RawMessage(`{"x":1}`))
got := tr.Get("new-key")
if string(got) != `{"x":1}` {
t.Fatalf("expected {\"x\":1}, got %s", got)
}
}
func TestMergeNonObjectFallback(t *testing.T) {
tr := New()
tr.Set("k", json.RawMessage(`{"a":"1"}`))
tr.Merge("k", json.RawMessage(`"plain string"`))
got := tr.Get("k")
if string(got) != `"plain string"` {
t.Fatalf("expected plain string fallback, got %s", got)
}
}
func TestDelete(t *testing.T) {
tr := New()
tr.Set("k", json.RawMessage(`{"data":true}`))
tr.Delete("k")
if got := tr.Get("k"); got != nil {
t.Fatalf("expected nil after delete, got %s", got)
}
}
func TestDeleteMissing(t *testing.T) {
tr := New()
tr.Delete("nonexistent")
}
func TestGetWithProvider(t *testing.T) {
tr := New()
tr.Set("k", json.RawMessage(`{"cached":"yes","kept":"ok"}`))
tr.RegisterProvider("k", func() json.RawMessage {
return json.RawMessage(`{"live":"data","cached":"overridden"}`)
})
got := tr.Get("k")
var m map[string]string
if err := json.Unmarshal(got, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if m["live"] != "data" {
t.Fatalf("expected live=data, got %q", m["live"])
}
if m["cached"] != "overridden" {
t.Fatalf("expected provider to override cached field, got %q", m["cached"])
}
if m["kept"] != "ok" {
t.Fatalf("expected kept=ok preserved, got %q", m["kept"])
}
}
func TestGetWithProviderDoesNotMutateCache(t *testing.T) {
tr := New()
tr.Set("k", json.RawMessage(`{"a":"1"}`))
tr.RegisterProvider("k", func() json.RawMessage {
return json.RawMessage(`{"b":"2"}`)
})
tr.Get("k")
// Read the raw cached entry — remove the provider to bypass merge.
tr.RegisterProvider("k", nil)
raw := tr.Get("k")
if string(raw) != `{"a":"1"}` {
t.Fatalf("cache was mutated: %s", raw)
}
}
func TestGetMultiWithProvider(t *testing.T) {
tr := New()
tr.Set("a", json.RawMessage(`{"x":"1"}`))
tr.Set("b", json.RawMessage(`{"y":"2"}`))
tr.RegisterProvider("a", func() json.RawMessage {
return json.RawMessage(`{"live":"yes"}`)
})
results := tr.GetMulti([]string{"a", "b"})
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
var m map[string]string
json.Unmarshal(results[0], &m)
if m["live"] != "yes" || m["x"] != "1" {
t.Fatalf("provider not applied to first result: %s", results[0])
}
// b has no provider — should return as-is
if string(results[1]) != `{"y":"2"}` {
t.Fatalf("unexpected second result: %s", results[1])
}
}
func TestGetWithProviderEmptyOverlay(t *testing.T) {
tr := New()
tr.Set("k", json.RawMessage(`{"a":"1"}`))
tr.RegisterProvider("k", func() json.RawMessage {
return nil
})
got := tr.Get("k")
if string(got) != `{"a":"1"}` {
t.Fatalf("nil overlay should return base, got %s", got)
}
}
func TestGetWithProviderNoBaseData(t *testing.T) {
tr := New()
tr.Set("k", json.RawMessage(nil))
tr.RegisterProvider("k", func() json.RawMessage {
return json.RawMessage(`{"live":"yes"}`)
})
got := tr.Get("k")
if string(got) != `{"live":"yes"}` {
t.Fatalf("expected overlay to win with empty base, got %s", got)
}
}
func TestConcurrentMerge(t *testing.T) {
tr := New()
tr.Set("shared", json.RawMessage(`{}`))
var wg sync.WaitGroup
const N = 50
for i := 0; i < N; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
tr.Merge("shared", json.RawMessage(`{"f`+string(rune('a'+i%26))+`":true}`))
}(i)
}
wg.Wait()
got := tr.Get("shared")
if got == nil {
t.Fatal("expected non-nil after concurrent merges")
}
}
@@ -0,0 +1,354 @@
// Package zapiwatcher subscribes to FRR zebra zserv route redistribution
// events and mirrors routing data into the operational YANG tree.
package zapiwatcher
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"net"
"strconv"
"strings"
"sync"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
"github.com/osrg/gobgp/v3/pkg/log"
"github.com/osrg/gobgp/v3/pkg/zebra"
)
const (
zapiSocketPath = "/var/run/frr/zserv.api"
zapiVersion = 6
zapiSoftware = "frr10.5"
reconnectInitial = 100 * time.Millisecond
reconnectMax = 30 * time.Second
reconnectFactor = 2.0
)
const (
RouteKernel zebra.RouteType = 1
RouteConnect zebra.RouteType = 2
RouteStatic zebra.RouteType = 3
RouteRIP zebra.RouteType = 4
RouteOSPF zebra.RouteType = 6
)
var subscribeTypes = []zebra.RouteType{
RouteKernel,
RouteConnect,
RouteStatic,
RouteRIP,
RouteOSPF,
}
var routeTypeToProtocol = map[zebra.RouteType]string{
RouteKernel: "infix-routing:kernel",
RouteConnect: "ietf-routing:direct",
RouteStatic: "ietf-routing:static",
RouteOSPF: "ietf-ospf:ospfv2",
RouteRIP: "ietf-rip:ripv2",
}
// ZAPIWatcher keeps the routing subtree in sync with zebra route updates.
type ZAPIWatcher struct {
tree *tree.Tree
log *slog.Logger
mu sync.Mutex
routes map[string]json.RawMessage
}
const routingTreeKey = "ietf-routing:routing"
// slogAdapter wraps slog.Logger to implement gobgp v3 log.Logger interface.
type slogAdapter struct {
l *slog.Logger
}
func (a *slogAdapter) Panic(msg string, fields log.Fields) { a.l.Error(msg, toAttrs(fields)...) }
func (a *slogAdapter) Fatal(msg string, fields log.Fields) { a.l.Error(msg, toAttrs(fields)...) }
func (a *slogAdapter) Error(msg string, fields log.Fields) { a.l.Error(msg, toAttrs(fields)...) }
func (a *slogAdapter) Warn(msg string, fields log.Fields) { a.l.Warn(msg, toAttrs(fields)...) }
func (a *slogAdapter) Info(msg string, fields log.Fields) { a.l.Info(msg, toAttrs(fields)...) }
func (a *slogAdapter) Debug(msg string, fields log.Fields) { a.l.Debug(msg, toAttrs(fields)...) }
func (a *slogAdapter) SetLevel(level log.LogLevel) {}
func (a *slogAdapter) GetLevel() log.LogLevel { return log.LogLevel(0) }
func toAttrs(fields log.Fields) []any {
attrs := make([]any, 0, len(fields)*2)
for k, v := range fields {
attrs = append(attrs, k, v)
}
return attrs
}
// New creates a ZAPIWatcher bound to the provided operational tree and logger.
func New(t *tree.Tree, log *slog.Logger) *ZAPIWatcher {
if log == nil {
log = slog.Default()
}
return &ZAPIWatcher{tree: t, log: log, routes: make(map[string]json.RawMessage)}
}
// Run starts the zebra watcher loop with exponential reconnect backoff.
//
// On successful connection it processes incoming route messages until
// disconnect or context cancellation. On disconnect, cached routes are cleared.
func (w *ZAPIWatcher) Run(ctx context.Context) error {
delay := reconnectInitial
for {
cli, err := w.connect(ctx)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
w.log.Warn("zapi watcher: connect failed", "err", err, "delay", delay)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
delay = time.Duration(math.Min(float64(delay)*reconnectFactor, float64(reconnectMax)))
continue
}
delay = reconnectInitial
w.log.Info("zapi watcher: connected", "socket", zapiSocketPath, "version", zapiVersion, "software", zapiSoftware)
err = w.processMessages(ctx, cli)
if ctx.Err() != nil {
return ctx.Err()
}
w.log.Warn("zapi watcher: disconnected", "err", err)
w.clearAllRoutes()
}
}
func (w *ZAPIWatcher) connect(ctx context.Context) (*zebra.Client, error) {
d := net.Dialer{}
conn, err := d.DialContext(ctx, "unix", zapiSocketPath)
if err != nil {
return nil, fmt.Errorf("dial zserv socket: %w", err)
}
_ = conn.Close()
software := zebra.NewSoftware(zapiVersion, zapiSoftware)
zebra.MaxSoftware = software
cli, err := zebra.NewClient(&slogAdapter{l: w.log}, "unix", zapiSocketPath, zebra.RouteType(0), zapiVersion, software, 0)
if err != nil {
return nil, fmt.Errorf("new zapi client: %w", err)
}
cli.SendHello()
cli.SendRouterIDAdd()
for _, typ := range subscribeTypes {
cli.SendRedistribute(typ, zebra.DefaultVrf)
}
return cli, nil
}
func (w *ZAPIWatcher) processMessages(ctx context.Context, cli *zebra.Client) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case msg, ok := <-cli.Receive():
if !ok {
return errors.New("zapi receive channel closed")
}
if msg == nil {
continue
}
w.handleMessage(msg)
}
}
}
func (w *ZAPIWatcher) handleMessage(msg *zebra.Message) {
ipr, ok := msg.Body.(*zebra.IPRouteBody)
if !ok || ipr == nil {
return
}
cmd := msg.Header.Command.ToCommon(zapiVersion, zebra.NewSoftware(zapiVersion, zapiSoftware))
switch cmd {
case zebra.RedistributeRouteAdd:
w.addRoute(ipr)
case zebra.RedistributeRouteDel:
w.deleteRoute(ipr)
}
}
func (w *ZAPIWatcher) addRoute(route *zebra.IPRouteBody) {
pfx, ok := ipNetFromPrefix(route.Prefix)
if !ok {
w.log.Debug("zapi watcher: ignore route add with invalid prefix")
return
}
rib := ribName(pfx)
key := rib + ":" + pfx.String() + ":" + routeProtocol(route.Type)
w.mu.Lock()
w.routes[key] = transformRoute(route)
w.mu.Unlock()
w.writeRibs()
}
func (w *ZAPIWatcher) deleteRoute(route *zebra.IPRouteBody) {
pfx, ok := ipNetFromPrefix(route.Prefix)
if !ok {
return
}
rib := ribName(pfx)
key := rib + ":" + pfx.String() + ":" + routeProtocol(route.Type)
w.mu.Lock()
delete(w.routes, key)
w.mu.Unlock()
w.writeRibs()
}
func (w *ZAPIWatcher) clearAllRoutes() {
w.mu.Lock()
w.routes = make(map[string]json.RawMessage)
w.mu.Unlock()
w.writeRibs()
}
func (w *ZAPIWatcher) writeRibs() {
w.mu.Lock()
ipv4Routes := make([]json.RawMessage, 0)
ipv6Routes := make([]json.RawMessage, 0)
for key, routeData := range w.routes {
if strings.HasPrefix(key, "ipv4-master:") {
ipv4Routes = append(ipv4Routes, routeData)
} else {
ipv6Routes = append(ipv6Routes, routeData)
}
}
w.mu.Unlock()
ribs := map[string]any{
"rib": []map[string]any{
{
"name": "ipv4-master",
"address-family": "ietf-routing:ipv4",
"routes": map[string]any{
"route": ipv4Routes,
},
},
{
"name": "ipv6-master",
"address-family": "ietf-routing:ipv6",
"routes": map[string]any{
"route": ipv6Routes,
},
},
},
}
ribsJSON, err := json.Marshal(map[string]any{"ribs": ribs})
if err != nil {
w.log.Error("zapi watcher: marshal ribs", "err", err)
return
}
w.tree.Merge(routingTreeKey, ribsJSON)
}
func ribName(prefix net.IPNet) string {
if prefix.IP.To4() != nil {
return "ipv4-master"
}
return "ipv6-master"
}
func transformRoute(route *zebra.IPRouteBody) json.RawMessage {
pfx, ok := ipNetFromPrefix(route.Prefix)
if !ok {
return json.RawMessage(`{}`)
}
nextHops := make([]map[string]any, 0, len(route.Nexthops))
for _, nh := range route.Nexthops {
hop := make(map[string]any)
if len(nh.Gate) > 0 && !nh.Gate.IsUnspecified() {
hop["next-hop-address"] = nh.Gate.String()
}
if nh.Ifindex > 0 {
ifi, err := net.InterfaceByIndex(int(nh.Ifindex))
if err == nil && ifi != nil && ifi.Name != "" {
hop["outgoing-interface"] = ifi.Name
} else {
hop["outgoing-interface"] = strconv.FormatUint(uint64(nh.Ifindex), 10)
}
}
if len(hop) > 0 {
nextHops = append(nextHops, hop)
}
}
routeNode := map[string]any{
"destination-prefix": pfx.String(),
"source-protocol": routeProtocol(route.Type),
"metric": route.Metric,
"next-hop-list": map[string]any{
"next-hop": nextHops,
},
}
encoded, err := json.Marshal(routeNode)
if err != nil {
return json.RawMessage(`{}`)
}
return json.RawMessage(encoded)
}
func routeProtocol(rt zebra.RouteType) string {
if protocol, ok := routeTypeToProtocol[rt]; ok {
return protocol
}
return "infix-routing:kernel"
}
func ipNetFromPrefix(prefix zebra.Prefix) (net.IPNet, bool) {
ip := prefix.Prefix
if len(ip) == 0 {
return net.IPNet{}, false
}
bits := 128
if v4 := ip.To4(); v4 != nil {
ip = v4
bits = 32
}
prefixLen := int(prefix.PrefixLen)
if prefixLen < 0 {
prefixLen = 0
}
if prefixLen > bits {
prefixLen = bits
}
mask := net.CIDRMask(prefixLen, bits)
return net.IPNet{IP: ip.Mask(mask), Mask: mask}, true
}
@@ -0,0 +1,185 @@
package zapiwatcher
import (
"encoding/json"
"net"
"testing"
"github.com/osrg/gobgp/v3/pkg/zebra"
)
func TestRouteProtocol(t *testing.T) {
tests := []struct {
name string
rt zebra.RouteType
expected string
}{
{"kernel", RouteKernel, "infix-routing:kernel"},
{"connect", RouteConnect, "ietf-routing:direct"},
{"static", RouteStatic, "ietf-routing:static"},
{"ospf", RouteOSPF, "ietf-ospf:ospfv2"},
{"rip", RouteRIP, "ietf-rip:ripv2"},
{"unknown defaults to kernel", zebra.RouteType(99), "infix-routing:kernel"},
{"zero defaults to kernel", zebra.RouteType(0), "infix-routing:kernel"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := routeProtocol(tc.rt)
if got != tc.expected {
t.Errorf("routeProtocol(%d) = %q, want %q", tc.rt, got, tc.expected)
}
})
}
}
func TestRibName(t *testing.T) {
tests := []struct {
name string
prefix net.IPNet
expected string
}{
{
"ipv4",
net.IPNet{IP: net.ParseIP("10.0.0.0").To4(), Mask: net.CIDRMask(24, 32)},
"ipv4-master",
},
{
"ipv6",
net.IPNet{IP: net.ParseIP("2001:db8::"), Mask: net.CIDRMask(64, 128)},
"ipv6-master",
},
{
"loopback v4",
net.IPNet{IP: net.ParseIP("127.0.0.0").To4(), Mask: net.CIDRMask(8, 32)},
"ipv4-master",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := ribName(tc.prefix)
if got != tc.expected {
t.Errorf("ribName(%v) = %q, want %q", tc.prefix, got, tc.expected)
}
})
}
}
func TestTransformRoute(t *testing.T) {
route := &zebra.IPRouteBody{
Type: RouteStatic,
Metric: 100,
}
route.Prefix.Prefix = net.ParseIP("10.0.0.0")
route.Prefix.PrefixLen = 24
result := transformRoute(route)
var parsed map[string]any
if err := json.Unmarshal(result, &parsed); err != nil {
t.Fatalf("unmarshal transformRoute result: %v", err)
}
if got := parsed["destination-prefix"]; got != "10.0.0.0/24" {
t.Errorf("destination-prefix = %v, want %q", got, "10.0.0.0/24")
}
if got := parsed["source-protocol"]; got != "ietf-routing:static" {
t.Errorf("source-protocol = %v, want %q", got, "ietf-routing:static")
}
if got, ok := parsed["metric"].(float64); !ok || int(got) != 100 {
t.Errorf("metric = %v, want 100", parsed["metric"])
}
nhList, ok := parsed["next-hop-list"].(map[string]any)
if !ok {
t.Fatalf("next-hop-list not a map: %T", parsed["next-hop-list"])
}
hops, ok := nhList["next-hop"].([]any)
if !ok {
t.Fatalf("next-hop not an array: %T", nhList["next-hop"])
}
if len(hops) != 0 {
t.Errorf("expected 0 next-hops, got %d", len(hops))
}
}
func TestTransformRouteIPv6(t *testing.T) {
route := &zebra.IPRouteBody{
Type: RouteOSPF,
Metric: 10,
}
route.Prefix.Prefix = net.ParseIP("2001:db8::")
route.Prefix.PrefixLen = 48
result := transformRoute(route)
var parsed map[string]any
if err := json.Unmarshal(result, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := parsed["destination-prefix"]; got != "2001:db8::/48" {
t.Errorf("destination-prefix = %v, want %q", got, "2001:db8::/48")
}
if got := parsed["source-protocol"]; got != "ietf-ospf:ospfv2" {
t.Errorf("source-protocol = %v, want %q", got, "ietf-ospf:ospfv2")
}
}
func TestIpNetFromPrefix(t *testing.T) {
tests := []struct {
name string
prefix zebra.Prefix
wantCIDR string
wantOK bool
}{
{
"valid ipv4",
zebra.Prefix{Prefix: net.ParseIP("192.168.1.0"), PrefixLen: 24},
"192.168.1.0/24",
true,
},
{
"valid ipv6",
zebra.Prefix{Prefix: net.ParseIP("2001:db8::"), PrefixLen: 64},
"2001:db8::/64",
true,
},
{
"host route ipv4",
zebra.Prefix{Prefix: net.ParseIP("10.0.0.1"), PrefixLen: 32},
"10.0.0.1/32",
true,
},
{
"zero prefix length",
zebra.Prefix{Prefix: net.ParseIP("0.0.0.0"), PrefixLen: 0},
"0.0.0.0/0",
true,
},
{
"prefix masking applied",
zebra.Prefix{Prefix: net.ParseIP("10.0.0.5"), PrefixLen: 24},
"10.0.0.0/24",
true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := ipNetFromPrefix(tc.prefix)
if ok != tc.wantOK {
t.Fatalf("ipNetFromPrefix ok = %v, want %v", ok, tc.wantOK)
}
if !ok {
return
}
if got.String() != tc.wantCIDR {
t.Errorf("ipNetFromPrefix = %q, want %q", got.String(), tc.wantCIDR)
}
})
}
}
+687
View File
@@ -0,0 +1,687 @@
# yangerd Phase 1: Core + ietf-system
**Status**: Complete
**Started**: 2026-03-28
**Design doc**: `src/statd/doc/yangerd-design.md`
## Overview
Build the yangerd Go daemon core infrastructure alongside the simplest
module (`ietf-system`). This establishes the project skeleton, in-memory
tree, IPC server, collector framework, and Buildroot packaging. All
subsequent modules build on this foundation.
## Constraints
- No CGo (hard, non-negotiable — design doc Section 2.3)
- Not a sysrepo plugin — yangerd has no sysrepo dependency
- `uint64`/`int64`/`decimal64` YANG types serialized as JSON strings (RFC 7951 / libyang)
- Socket owned by `root:yangerd`, permissions `0660`
- Sources at `src/yangerd/`, module path `github.com/kernelkit/infix/src/yangerd`
- Buildroot: vendored deps (`GOFLAGS=-mod=vendor`), `$(eval $(golang-package))`
- NTP handled by separate `internal/collector/ntp.go` — excluded from system.go
- Ignore gRPC for FRR
## Steps
### Step 1: Project scaffolding
- **Status**: ✅ complete
- `src/yangerd/go.mod``module github.com/kernelkit/infix/src/yangerd`, go 1.21
- `cmd/yangerd/main.go` — daemon entry: config → socket → tree → collectors → ready → signal → IPC serve
- `cmd/yangerctl/main.go` — CLI: get/health/dump subcommands with --socket/--timeout flags
- Directory tree per design doc Section 6
### Step 2: Core tree (`internal/tree/tree.go`)
- **Status**: ✅ complete
- `modelEntry` struct: `sync.RWMutex`, `json.RawMessage`, `time.Time`
- `Tree` struct: top-level `sync.RWMutex` protecting `map[string]*modelEntry`
- Methods: `New()`, `Set()`, `Get()`, `GetMulti()`, `Keys()`, `Info()`
- Double-checked locking in `Set()` for new keys
- Eventual consistency in `GetMulti()` (per-model read locks, not snapshot)
### Step 3: IPC protocol + server
- **Status**: ✅ complete
- `internal/ipc/protocol.go` — framing (ver:1 + uint32 BE length + JSON), Request/Response types
- `internal/ipc/server.go` — Unix socket listener, connection handler, method routing, ready flag
- `internal/ipc/client.go` — Dial/query helper for yangerctl
### Step 4: Collector framework
- **Status**: ✅ complete
- `internal/collector/collector.go` — Collector interface + RunAll() scheduling
- `internal/collector/runner.go` — CommandRunner + FileReader interfaces + production impls
### Step 5: System collector (`internal/collector/system.go`)
- **Status**: ✅ complete
- ~658 lines implementing full ietf-system data collection
- Produces: `ietf-system:system` and `ietf-system:system-state` tree keys
- Sub-methods: addHostname, addTimezone, addUsers, addPlatform, addClock,
addSoftware, addSoftwareSlots, getBootOrder, addDNS, addServices, addResourceUsage,
addMemory, addLoadAvg, addFilesystems
### Step 6: Config (`internal/config/config.go`)
- **Status**: ✅ complete
- Env var parsing: YANGERD_SOCKET, LOG_LEVEL, STARTUP_TIMEOUT, POLL_INTERVAL_SYSTEM,
ENABLE_WIFI/CONTAINERS/GPS
### Step 7: Wire main.go startup
- **Status**: ✅ complete
- Config → socket → tree → collectors → ready → signal handling → IPC serve
### Step 8: yangerctl (`cmd/yangerctl/main.go`)
- **Status**: ✅ complete
- Subcommands: get, health, dump with --socket/--timeout flags
### Step 9: Tests
- **Status**: ✅ complete
- `internal/tree/tree_test.go` — Set/Get/GetMulti/Keys/Info + concurrent access (5 tests)
- `internal/ipc/protocol_test.go` — framing round-trip, version mismatch, oversized payload (3 tests)
- `internal/ipc/server_test.go` — get/dump/health/notReady/unknownMethod (5 tests)
- `internal/collector/system_test.go` — 17 tests covering all data sources, edge cases, failures
- `internal/testutil/mock.go` — reusable MockRunner + MockFileReader
- All tests pass with `-race` flag
### Step 10: Buildroot packaging
- **Status**: ✅ complete
- `package/yangerd/yangerd.mk` — golang-package recipe with build targets + feature flags
- `package/yangerd/Config.in` — Kconfig entry with Go arch dependency
- `package/yangerd/yangerd.svc` — Finit service definition
- `package/Config.in` — source entry added
## Bug fixes found during testing
- **`fmt.Sprintf("%v", float64)` producing scientific notation**: In `addServices()`, JSON-decoded
float64 values like `4096000.0` were formatted as `"4.096e+06"` instead of `"4096000"`. Fixed by
using `strconv.Itoa(toInt(...))` for uint64 string encoding. Same fix applied to slot size in
`addSoftwareSlots()`.
## File inventory
### Production code (12 files)
| File | Lines | Description |
|------|-------|-------------|
| `go.mod` | 3 | Module definition |
| `internal/config/config.go` | ~85 | Environment variable parsing |
| `internal/tree/tree.go` | 119 | Per-model locked tree store |
| `internal/ipc/protocol.go` | ~120 | IPC framing + request/response types |
| `internal/ipc/server.go` | ~150 | Unix socket server |
| `internal/ipc/client.go` | ~80 | Client helper for yangerctl |
| `internal/collector/collector.go` | 51 | Collector interface + RunAll scheduler |
| `internal/collector/runner.go` | 40 | CommandRunner/FileReader interfaces |
| `internal/collector/system.go` | 658 | System data collector |
| `cmd/yangerd/main.go` | ~100 | Daemon entry point |
| `cmd/yangerctl/main.go` | ~120 | CLI diagnostic tool |
### Test code (5 files)
| File | Tests | Description |
|------|-------|-------------|
| `internal/tree/tree_test.go` | 5 | Tree operations + concurrency |
| `internal/ipc/protocol_test.go` | 3 | Framing protocol |
| `internal/ipc/server_test.go` | 5 | IPC server end-to-end |
| `internal/collector/system_test.go` | 17 | System collector with mocks |
| `internal/testutil/mock.go` | — | Shared test mocks |
### Buildroot packaging (4 files)
| File | Description |
|------|-------------|
| `package/yangerd/yangerd.mk` | Buildroot Go package recipe |
| `package/yangerd/Config.in` | Kconfig menu entry |
| `package/yangerd/yangerd.svc` | Finit service definition |
| `package/Config.in` | Updated: added yangerd source |
## Reference files
| File | Purpose |
|------|---------|
| `src/statd/doc/yangerd-design.md` | Authoritative design document |
| `src/statd/python/yanger/ietf_system.py` | Python reference impl (461 lines) |
| `src/confd/yang/confd/ietf-system@2014-08-06.yang` | Standard YANG model |
| `src/confd/yang/confd/infix-system.yang` | Infix augmentations |
| `src/confd/yang/confd/infix-system-software.yang` | Software submodule |
| `src/netbrowse/` | Existing Go project (pattern reference) |
| `package/netbrowse/netbrowse.mk` | Buildroot Go package template |
## Notes
- NTP data is handled by `internal/collector/ntp.go` (Phase 2), not system.go
- DNS statistics (cache-size, cache-hits, cache-misses from infix-system.yang) not in current Python impl
- `addFilesystems` uses `syscall.Statfs()` directly (not mockable via FileReader); not unit-tested
- `addHostname` uses `os.Hostname()` directly; not unit-tested in isolation
- `addTimezone` uses `filepath.EvalSymlinks()` directly; not unit-tested in isolation
---
# yangerd Phase 2: Polling Collectors
**Status**: Complete
**Started**: 2026-03-28
**Completed**: 2026-03-29
## Overview
Implement all polling-based collectors that replace the Python yanger
scripts. Reactive collectors (netlink/interfaces, ZAPI/routing-table,
LLDP, D-Bus/DHCP/firewall) are deferred to Phase 3.
## Collectors implemented
### RoutingCollector (`routing.go`, ~748 lines)
- **Tree key**: `ietf-routing:routing`
- **Interval**: 10s (configurable: `YANGERD_POLL_INTERVAL_ROUTING`)
- Merges OSPF, RIP, and BFD into a single `control-plane-protocols` list
- OSPF: `ospf-status` helper + `vtysh show ip ospf json` for areas, interfaces, neighbors, routes
- RIP: `vtysh -c 'show ip rip status'` text parsing + `show ip route rip json`
- BFD: `vtysh -c 'show bfd peers json'`, filters out multihop sessions
### NTPCollector (`ntp.go`, ~434 lines)
- **Tree key**: `ietf-ntp:ntp`
- **Interval**: 60s (configurable: `YANGERD_POLL_INTERVAL_NTP`)
- Parses `chronyc -c` CSV output: sources, sourcestats, tracking, serverstats
- Detects NTP listening port via `ss -ulnp`
- Infix augmentations: clock-state frequency/offset details
### HardwareCollector (`hardware.go`, ~1122 lines)
- **Tree key**: `ietf-hardware:hardware`
- **Interval**: 10s (configurable: `YANGERD_POLL_INTERVAL_HARDWARE`)
- Motherboard from `/run/system.json`
- VPD components with vendor extensions
- USB port components with lock/unlock state
- hwmon sensors: temp, fan, PWM, voltage, current, power with parent/child relationships
- Thermal zones from `/sys/class/thermal/`
- WiFi radios via `iw.py` (gated by `YANGERD_ENABLE_WIFI`)
- GPS receivers via gpsd TCP (gated by `YANGERD_ENABLE_GPS`)
### ContainerCollector (`containers.go`, ~466 lines)
- **Tree key**: `infix-containers:containers`
- **Interval**: 10s (configurable: `YANGERD_POLL_INTERVAL_CONTAINERS`)
- Feature-gated by `YANGERD_ENABLE_CONTAINERS`
- Podman ps/inspect/stats integration
- Cgroup v2 resource limits (memory.max, cpu.max)
- Network info (host/bridge + port publishing)
- Resource usage stats (memory, CPU, block I/O, net I/O, PIDs)
## Bug fixes
- **NTP `ss` parsing**: `addServerStatus()` used `fields[4]` for local address
in `ss -ulnp` output, but `strings.Fields` puts local address at index 3.
Fixed to `fields[3]`.
## Test summary
| File | Tests | Status |
|------|-------|--------|
| `routing_test.go` | 16 | ✅ pass |
| `ntp_test.go` | 10 | ✅ pass |
| `hardware_test.go` | 10 | ✅ pass |
| `containers_test.go` | 12 | ✅ pass |
| **Phase 2 total** | **48** | **✅ all pass** |
| **Overall total** | **65** | **✅ all pass** |
## File inventory (Phase 2 additions)
### Production code (4 files)
| File | Lines | Description |
|------|-------|-------------|
| `internal/collector/routing.go` | 748 | OSPF+RIP+BFD routing collector |
| `internal/collector/ntp.go` | 434 | NTP collector (chronyc) |
| `internal/collector/hardware.go` | 1122 | Hardware/sensor collector |
| `internal/collector/containers.go` | 466 | Container collector (podman) |
### Test code (4 files)
| File | Tests | Description |
|------|-------|-------------|
| `internal/collector/routing_test.go` | 16 | Routing protocol tests |
| `internal/collector/ntp_test.go` | 10 | NTP chronyc parsing tests |
| `internal/collector/hardware_test.go` | 10 | Sensor/VPD/thermal tests |
| `internal/collector/containers_test.go` | 12 | Podman/cgroup parsing tests |
### Modified files
| File | Change |
|------|--------|
| `internal/config/config.go` | Added PollRouting/NTP/Hardware/Containers fields |
| `cmd/yangerd/main.go` | Registered all Phase 2 collectors |
---
# yangerd Phase 3: Reactive/Event-Driven Collectors
**Status**: Complete
**Started**: 2026-03-29
**Completed**: 2026-03-29
## Overview
Reactive collectors replace polling with persistent subscriptions (netlink,
ZAPI, D-Bus signals, subprocess watchers). They implement a
`Run(ctx context.Context) error` goroutine pattern instead of the polling
`Collector` interface.
## Packages implemented
### Foundation infrastructure
| Package | File | Lines | Description |
|---------|------|-------|-------------|
| `ipbatch` | `internal/ipbatch/ipbatch.go` | 212 | Persistent `ip -json` subprocess with mutex-serialized Query(), exponential backoff restart (100ms→30s), canary queries, 4MiB scanner |
| `bridgebatch` | `internal/bridgebatch/bridgebatch.go` | 201 | Persistent `bridge -json` subprocess, same pattern as ipbatch |
| `fswatcher` | `internal/fswatcher/fswatcher.go` | 176 | Inotify watcher with per-path debounce, glob expansion, initial read |
### Reactive monitors
| Package | File | Lines | Description |
|---------|------|-------|-------------|
| `monitor` | `internal/monitor/monitor.go` | 509 | NLMonitor — netlink link/addr/neigh/MDB subscription, stores raw ip-json at sub-paths |
| `ethmonitor` | `internal/ethmonitor/ethmonitor.go` | 223 | EthMonitor — ethtool genetlink for speed/duplex/auto-negotiation |
| `iwmonitor` | `internal/iwmonitor/iwmonitor.go` | 311 | IWMonitor — `iw event -t` parser + station dump/info queries |
| `lldpmonitor` | `internal/lldpmonitor/lldpmonitor.go` | 315 | LLDPMonitor — `lldpcli json0 watch` for neighbor discovery |
| `zapiwatcher` | `internal/zapiwatcher/zapiwatcher.go` | 305 | ZAPIWatcher — FRR ZAPI v6 route redistribution |
| `dbusmonitor` | `internal/dbusmonitor/dbusmonitor.go` | 1020 | DBusMonitor — dnsmasq lease events + firewalld Reloaded/NameOwnerChanged |
### Transformer
| Package | File | Lines | Description |
|---------|------|-------|-------------|
| `iface` | `internal/iface/iface.go` | 813 | Pure interface transformer: raw `ip -json` → YANG JSON (type mapping, oper-state, counters, IPv4/IPv6, VLAN/VETH/GRE/VXLAN/LAG/bridge augments) |
## External dependencies added
- `github.com/fsnotify/fsnotify` v1.9.0
- `github.com/vishvananda/netlink`
- `github.com/mdlayher/genetlink` + `github.com/mdlayher/ethtool`
- `github.com/osrg/gobgp/v4/pkg/zebra`
- `github.com/godbus/dbus/v5`
## Key design decisions
- **Event-as-trigger pattern**: Most reactive sources use events solely as triggers
and then re-read canonical data (e.g., netlink notification triggers `ip -json link show`)
- **Persistent subprocess managers** (IPBatch/BridgeBatch): Mutex-serialized Query(),
dead/alive atomic state, ErrBatchDead sentinel error, exponential backoff restart
- **Iface transformer is pure**: Takes raw `ip -json` arrays, returns YANG JSON.
Uses `FileChecker` interface for IPv6 MTU and WiFi detection
- **Interface filtering**: Skip `group=="internal"` or `link_type` in `("can","vcan")`
- **RFC 7951 compliance**: Counter values (uint64) encoded as JSON strings
## Test summary
| File | Tests | Status |
|------|-------|--------|
| `internal/iface/iface_test.go` | 20+ | ✅ pass |
| `internal/iwmonitor/iwmonitor_test.go` | 8+ | ✅ pass |
| `internal/lldpmonitor/lldpmonitor_test.go` | 8+ | ✅ pass |
| `internal/monitor/monitor_test.go` | 8+ | ✅ pass |
| `internal/dbusmonitor/dbusmonitor_test.go` | 25+ | ✅ pass |
| `internal/zapiwatcher/zapiwatcher_test.go` | 8 | ✅ pass |
| `internal/fswatcher/fswatcher_test.go` | 11 | ✅ pass |
| **Phase 3 total** | **93** | **✅ all pass** |
| **Overall total** | **158** | **✅ all pass with -race** |
## Modified files
| File | Change |
|------|--------|
| `internal/config/config.go` | Added reactive config fields (ZAPISocket, DBus paths, WiFi/LLDP enables) |
| `cmd/yangerd/main.go` | All reactive subsystems wired (229 lines total) |
## Packages without tests (intentional)
- `ipbatch`, `bridgebatch` — require real subprocesses (`ip`, `bridge`)
- `ethmonitor` — requires genetlink socket (kernel interface)
---
# yangerd Phase 4: Architecture Fix — Transform-on-Write
**Status**: Complete
**Started**: 2026-03-29
**Completed**: 2026-03-29
## Problem
NLMonitor stored raw ip-json fragments at per-interface sub-paths
(e.g. `/ietf-interfaces:interfaces/interface[name='eth0']`,
`/addresses`, `/statistics`). These are not valid YANG keys and the
output did not match what Python yanger produces — a single complete
`{"ietf-interfaces:interfaces":{"interface":[...]}}` document.
The `iface.Transform()` function existed but was never called.
EthMonitor and IWMonitor also stored at fragment paths.
## Fix: Transform-on-write
NLMonitor is now the central coordinator. It owns staging data (raw
link/addr/stats arrays) and after every netlink event:
1. Runs `iface.Transform(links, addrs, stats, fc)` to produce the base
YANG document
2. Merges augment data (ethernet, wifi, bridge FDB/MDB) from staging
maps into the matching interface entries
3. Stores the complete result at a single tree key `ietf-interfaces:interfaces`
EthMonitor and IWMonitor no longer write to the tree directly. They call
`NLMonitor.SetEthernetData(ifname, data)` and
`NLMonitor.SetWifiData(ifname, data)` which update staging maps and
trigger a rebuild.
## Changes
| File | Change |
|------|--------|
| `internal/monitor/monitor.go` | Added staging fields (`links`, `addrs`, `stats`, `fdb`, `mdb`, `ethernet`, `wifi`), `rebuild()` method calling `iface.Transform()` + `mergeAugments()`, `replaceByIfName()` helper, `SetEthernetData()`/`SetWifiData()` public methods. Removed per-interface fragment tree paths. Added `iface.FileChecker` parameter to `New()`. |
| `internal/monitor/monitor_test.go` | Removed `TestPathHelpers` (old fragment paths). Added tests: `TestReplaceByIfName`, `TestReplaceByIfNamePreservesUpdatedData`, `TestMergeAugments`, `TestMergeAugmentsNoOp`, `TestMergeAugmentsInvalidDoc`, `TestTreeKey`. |
| `internal/ethmonitor/ethmonitor.go` | Removed `tree` field and tree import. Added `onUpdate` callback. `New()` no longer takes `*tree.Tree`. `refreshEthernetSettings()` calls `onUpdate(ifname, data)` instead of `tree.Set()`. |
| `internal/iwmonitor/iwmonitor.go` | Removed `tree` field and tree import. Added `onUpdate` callback + `publishWifi()` method. `New()` no longer takes `*tree.Tree`. Assembles combined wifi JSON per interface. |
| `cmd/yangerd/main.go` | Added `osFileChecker` type implementing `iface.FileChecker`. Updated `monitor.New()` call with FileChecker. Wired `ethMon.SetOnUpdate(nlmon.SetEthernetData)` and `iwmon.SetOnUpdate(nlmon.SetWifiData)`. Updated `ethmonitor.New()` and `iwmonitor.New()` signatures. |
## Test summary
| Metric | Value |
|--------|-------|
| New tests added | 5 |
| Tests removed | 1 (TestPathHelpers — obsolete fragment paths) |
| **Total tests** | **163** |
| **Status** | **✅ all pass with -race** |
---
# yangerd Phase 5: Buildroot Compatibility Fixes
**Status**: Complete
**Completed**: 2026-03-29
## Problem
`make yangerd-rebuild` failed: Buildroot ships Go 1.23.12 but go.mod
had `go 1.24.5` (from gobgp/v4's requirement). Two other dependencies
also required go 1.24+.
## Fixes
| Change | Before | After | Reason |
|--------|--------|-------|--------|
| `go.mod` go directive | `go 1.24.5` | `go 1.23.0` | Buildroot has Go 1.23.12 |
| `osrg/gobgp` | v4 (go 1.24.5) | v3 v3.37.0 (go 1.23.0) | All v4 releases need 1.24+ |
| `mdlayher/ethtool` | v0.5.1 (go 1.24.0) | v0.4.1 (go 1.23.0) | API-compatible downgrade |
| `golang.org/x/sys` | v0.40.0 (go 1.24.0) | v0.35.0 (go 1.23.0) | Sufficient for all deps |
### gobgp v3 vs v4 API changes
- `Nexthop.Gate`: `netip.Addr` (v4) → `net.IP` (v3)
- `Prefix.Prefix`: `netip.Addr` (v4) → `net.IP` (v3)
- `NewClient()`: `*slog.Logger` (v4) → `log.Logger` interface (v3)
- Created `slogAdapter` in zapiwatcher.go to bridge `*slog.Logger` → gobgp v3 `log.Logger`
### ip batch `-s` flag fix
`ip -json -force -batch -` does not accept `-s` as a batch line subcommand.
Added `-s -d` as global flags to the persistent batch process args instead.
Removed separate `-s link show` queries and the `stats` staging field.
## Verification
- All 163 tests pass with `-mod=vendor` and `-race`
- `go build ./cmd/yangerd` and `go build ./cmd/yangerctl` succeed
- `go vet ./...` clean
---
# yangerd Phase 6: statd C Integration
**Status**: Complete (code-complete, pending real device testing)
**Completed**: 2026-03-29
## Overview
statd (C daemon) now queries yangerd over Unix socket IPC instead of
fork/exec'ing Python yanger scripts, with automatic fallback to yanger
when yangerd is unavailable.
## Architecture
```
statd (C) ──yangerd_query()──► /run/yangerd.sock ──► yangerd (Go)
│ │
│ fallback if yangerd unavailable │ tree.Get(key)
▼ ▼
ly_add_yanger_data() in-memory Tree store
(fork/exec Python) (per-module JSON blobs)
```
## Wire protocol (C↔Go)
```
Frame: [ver:1byte=0x01] [length:4bytes big-endian] [JSON body]
Request: {"method":"get","path":"ietf-interfaces:interfaces"}
Response: {"status":"ok","data":{"ietf-interfaces:interfaces":{...}}}
```
## Files created
| File | Lines | Description |
|------|-------|-------------|
| `src/statd/yangerd.h` | 28 | Header: socket path, timeout, max payload, proto version, `yangerd_query()` |
| `src/statd/yangerd.c` | 245 | Full C IPC client: connect, framed I/O, jansson JSON parsing |
## Files modified
| File | Change |
|------|--------|
| `src/statd/statd.c` | Added `#include "yangerd.h"`, `ly_add_yangerd_data()` wrapper, `xpath_to_yangerd_path()` helper; updated all 5 sysrepo callbacks |
| `src/statd/Makefile.am` | Added `yangerd.c yangerd.h` to `statd_SOURCES` |
## Key functions
- **`yangerd_query(path, &buf, &len)`** — Connect to socket, send framed "get" request, receive framed response, extract "data" JSON field
- **`ly_add_yangerd_data(ctx, parent, path, yanger_args)`** — Try yangerd first; on failure, fall back to `ly_add_yanger_data()` (fork/exec Python)
- **`xpath_to_yangerd_path(xpath, buf, bufsz)`** — Strip leading `/`, take first path segment (maps sysrepo xpath to yangerd tree key)
## Subscription → yangerd key mapping
| statd subscription xpath | yangerd tree key |
|---|---|
| `/ietf-interfaces:interfaces` | `ietf-interfaces:interfaces` |
| `/ietf-routing:routing/ribs` | `ietf-routing:routing` |
| `/ietf-hardware:hardware` | `ietf-hardware:hardware` |
| `/ietf-system:system` | `ietf-system:system` |
| `/ietf-system:system-state` | `ietf-system:system-state` |
| `/ieee802-dot1ab-lldp:lldp` | `ieee802-dot1ab-lldp:lldp` |
| `/infix-containers:containers` | `infix-containers:containers` |
| `/infix-dhcp-server:dhcp-server` | `infix-dhcp-server:dhcp-server` |
| `/infix-firewall:firewall` | `infix-firewall:firewall` |
| `/ietf-ntp:ntp` | `ietf-ntp:ntp` |
| OSPF/RIP/BFD callbacks | `ietf-routing:routing` (hardcoded) |
## Remaining
- ~~Real device testing~~ Done — bugs found and fixed (see Phase 7)
- ~~Verify fallback~~ Removed — no fallback by design
- Performance comparison (optional): yangerd IPC vs fork/exec Python yanger
---
# yangerd Phase 7: Real Device Bug Fixes
**Status**: Complete (Bugs 1-7)
**Completed**: 2026-03-30
## Overview
Two rounds of `yangerctl dump` on a real Infix x86_64 device exposed
seven bugs across four categories. All seven are now fixed.
## Bug 1: Double-wrapping (FIXED)
### Problem
The IPC server's `handleGet()` wraps stored data in `{key: data}`, but
some collectors ALSO stored their data pre-wrapped. This caused
double-nesting, e.g. `{"ietf-interfaces:interfaces":{"ietf-interfaces:interfaces":{...}}}`.
### Contract established
Collectors store data WITHOUT the module key wrapper. The server adds it.
### Fixes
| File | Change |
|------|--------|
| `internal/iface/iface.go` | `Transform()` returns `{"interface":[...]}` (removed outer wrapper) |
| `internal/iface/iface_test.go` | Updated `mustInterfaces()` to parse unwrapped format |
| `internal/monitor/monitor.go` | `mergeAugments()` updated to parse unwrapped format |
| `internal/monitor/monitor_test.go` | `TestMergeAugments`/`TestMergeAugmentsNoOp` updated |
| `internal/dbusmonitor/dbusmonitor.go` | `buildDHCPTree()`/`buildFirewallTree()` removed wrappers |
| `internal/dbusmonitor/dbusmonitor_test.go` | Tests updated for unwrapped format |
## Bug 2: Fragmented routing tree keys (FIXED)
### Problem
`ietf-routing:routing` is a shared tree written by three different
sources — RoutingCollector (control-plane-protocols), ZAPIWatcher (ribs),
and FSWatcher (forwarding interfaces). Each wrote to its own sub-path
key or used `tree.Set()` which overwrote the others' data.
### Solution: `Tree.Merge()`
Added a shallow first-level JSON merge method to `Tree` that allows each
writer to merge its fields into the shared key without overwriting
others' data. All three writers now use `tree.Merge("ietf-routing:routing", ...)`.
### Fixes
| File | Change |
|------|--------|
| `internal/tree/tree.go` | Added `Merge()` and `Delete()` methods |
| `internal/tree/tree_test.go` | 7 new tests (Merge subtests, empty, non-object, delete, concurrent) |
| `internal/zapiwatcher/zapiwatcher.go` | Refactored: internal `routes` map, builds complete ribs, writes via `Merge()` |
| `internal/zapiwatcher/zapiwatcher_test.go` | Removed obsolete `routePath`/`routeKey` tests |
| `internal/collector/routing.go` | Changed `t.Set()``t.Merge()` on line 59 |
| `internal/fswatcher/fswatcher.go` | Added `UseMerge` field to `WatchHandler`; `InitialRead()`/`fireHandler()` respect it |
| `cmd/yangerd/main.go` | Replaced per-file `forwardingTreeKey()` with `forwardingAggregator` that scans all forwarding files and writes a complete `interfaces` list via `Merge()` |
### Forwarding aggregator
Replaces the old per-file approach (each `/proc/sys/net/ipv{4,6}/conf/*/forwarding`
file got its own sub-path tree key) with an aggregator that:
1. On any forwarding file change, rescans ALL forwarding files
2. Builds the complete `{"interfaces":{"interface":["e1","e2",...]}}` list
3. Writes via `tree.Merge("ietf-routing:routing", ...)` — coexists with ribs and control-plane-protocols
4. Matches Python yanger `get_routing_interfaces()` output format
5. Uses `forwarding` for IPv4, `force_forwarding` for IPv6 (matching Python behavior)
## Bug 3: Duplicate interface entries (FIXED)
### Problem
When Infix renames `eth0` to `e1`, `ip -json link show` reports both the
old and new names with the same ifindex. Both appeared in the YANG output.
### Solution: `dedup()` in iface transformer
Added `dedup()` function that runs before `skipInterface()`. When
multiple entries share the same ifindex, keeps the one with
`operstate=="UP"` (or the first seen if neither is UP).
### Fixes
| File | Change |
|------|--------|
| `internal/iface/iface.go` | Added `dedup()` function; `Transform()` calls `dedup(decodeObjects(linkData))` |
| `internal/iface/iface_test.go` | 4 new test cases: UP-over-DOWN, both-DOWN-keeps-first, different-ifindex, zero-ifindex |
## Verification
- All tests pass (including new tests for Merge, dedup, and forwarding aggregator)
- `go build ./cmd/yangerd` and `go build ./cmd/yangerctl` succeed
- `go vet ./...` clean
## Bug 4: Interface removal not handled (FIXED)
### Problem
When an interface is removed, its `/proc/sys/net/*/conf/IFNAME/forwarding`
file disappears. The inotify Remove event was only calling `rewatch()`,
not updating the tree — the removed interface stayed in the YANG data.
### Solution: `handleRemove()` in FSWatcher
Replaced the old Remove→handleEvent→rewatch sequence with a dedicated
`handleRemove()` method that handles the two handler types differently:
- **UseMerge handlers** (forwarding aggregator): fires the handler, which
rescans via glob and naturally excludes the removed file
- **Plain handlers**: calls `tree.Delete()` to clear stale data
After handling, attempts to re-add the inotify watch. If the file is
permanently gone, cleans up the handler and debounce timer entries.
### Fixes
| File | Change |
|------|--------|
| `internal/fswatcher/fswatcher.go` | Added `handleRemove()`, removed `rewatch()`, `Run()` dispatches Remove to `handleRemove()` |
| `internal/fswatcher/fswatcher_test.go` | 4 new tests: merge-handler removal, plain-handler removal, unknown path, rewatch-succeeds |
## Bug 5: Phantom GPS devices (FIXED)
### Problem
`gps0``gps3` appeared in hardware output even when `/dev/gps*` didn't
exist. `readlink -f` on non-existent paths succeeds (returns the
canonical form), so the loop at `hardware.go:941` never skipped them.
### Solution
Added an `ls /dev/gpsN` existence check before the `readlink -f` call,
matching the Python reference (`ietf_hardware.py:727`: `HOST.exists()`).
### Fixes
| File | Change |
|------|--------|
| `internal/collector/hardware.go` | Added `ls` existence check before `readlink -f` in GPS loop |
| `internal/collector/hardware_test.go` | `TestHardwareGPSDeviceNotFound` — verifies no phantom GPS when devices missing |
## Bug 6: `null` JSON arrays instead of `[]` (FIXED)
### Problem
Go `nil` slices marshal to `null` in JSON. YANG lists must be arrays,
so libyang rejects `null`. Three locations used `var slice []Type`
(nil) instead of `make([]Type, 0)` (empty array).
### Fixes
| File | Line | Change |
|------|------|--------|
| `cmd/yangerd/main.go` | 282 | `var ifnames []string``ifnames := make([]string, 0)` |
| `internal/collector/system.go` | 172 | `var users []interface{}``users := make([]interface{}, 0)` |
| `internal/collector/system.go` | 422 | `var servers []interface{}``servers := make([]interface{}, 0)` |
| `internal/collector/system_test.go` | — | `TestSystemCollectorNoUsersEmptyArray`, `TestSystemCollectorNoDNSEmptyArray` |
## Bug 7: Firewall data without firewalld running (FIXED)
### Problem
`refreshFirewall()` was called on initial D-Bus connect. When firewalld
wasn't on the bus, all D-Bus calls failed but execution continued.
`getFirewallPolicies()` unconditionally appended a hardcoded
"default-drop" policy, producing phantom firewall data.
### Solution
Made `getDefaultZone` the gate: if it fails, `refreshFirewall()` returns
early without writing to the tree. This is the minimal fix — the
`NameOwnerChanged` handler already clears the tree when firewalld exits.
### Fixes
| File | Change |
|------|--------|
| `internal/dbusmonitor/dbusmonitor.go` | `refreshFirewall()` returns early if `getDefaultZone` fails |
## Verification
- All tests pass (173+ tests)
- `go build ./cmd/yangerd` and `go build ./cmd/yangerctl` succeed
- `go vet ./...` clean
- Ready for re-test on real device
+14
View File
@@ -0,0 +1,14 @@
freebsd_task:
name: 'FreeBSD'
freebsd_instance:
image_family: freebsd-14-2
install_script:
- pkg update -f
- pkg install -y go
test_script:
# run tests as user "cirrus" instead of root
- pw useradd cirrus -m
- chown -R cirrus:cirrus .
- FSNOTIFY_BUFFER=4096 sudo --preserve-env=FSNOTIFY_BUFFER -u cirrus go test -parallel 1 -race ./...
- sudo --preserve-env=FSNOTIFY_BUFFER -u cirrus go test -parallel 1 -race ./...
- FSNOTIFY_DEBUG=1 sudo --preserve-env=FSNOTIFY_BUFFER -u cirrus go test -parallel 1 -race -v ./...
@@ -0,0 +1,10 @@
# go test -c output
*.test
*.test.exe
# Output of go build ./cmd/fsnotify
/fsnotify
/fsnotify.exe
/test/kqueue
/test/a.out
+2
View File
@@ -0,0 +1,2 @@
Chris Howey <howeyc@gmail.com> <chris@howey.me>
Nathan Youngman <git@nathany.com> <4566+nathany@users.noreply.github.com>
+602
View File
@@ -0,0 +1,602 @@
# Changelog
1.9.0 2024-04-04
----------------
### Changes and fixes
- all: make BufferedWatcher buffered again ([#657])
- inotify: fix race when adding/removing watches while a watched path is being
deleted ([#678], [#686])
- inotify: don't send empty event if a watched path is unmounted ([#655])
- inotify: don't register duplicate watches when watching both a symlink and its
target; previously that would get "half-added" and removing the second would
panic ([#679])
- kqueue: fix watching relative symlinks ([#681])
- kqueue: correctly mark pre-existing entries when watching a link to a dir on
kqueue ([#682])
- illumos: don't send error if changed file is deleted while processing the
event ([#678])
[#657]: https://github.com/fsnotify/fsnotify/pull/657
[#678]: https://github.com/fsnotify/fsnotify/pull/678
[#686]: https://github.com/fsnotify/fsnotify/pull/686
[#655]: https://github.com/fsnotify/fsnotify/pull/655
[#681]: https://github.com/fsnotify/fsnotify/pull/681
[#679]: https://github.com/fsnotify/fsnotify/pull/679
[#682]: https://github.com/fsnotify/fsnotify/pull/682
1.8.0 2024-10-31
----------------
### Additions
- all: add `FSNOTIFY_DEBUG` to print debug logs to stderr ([#619])
### Changes and fixes
- windows: fix behaviour of `WatchList()` to be consistent with other platforms ([#610])
- kqueue: ignore events with Ident=0 ([#590])
- kqueue: set O_CLOEXEC to prevent passing file descriptors to children ([#617])
- kqueue: emit events as "/path/dir/file" instead of "path/link/file" when watching a symlink ([#625])
- inotify: don't send event for IN_DELETE_SELF when also watching the parent ([#620])
- inotify: fix panic when calling Remove() in a goroutine ([#650])
- fen: allow watching subdirectories of watched directories ([#621])
[#590]: https://github.com/fsnotify/fsnotify/pull/590
[#610]: https://github.com/fsnotify/fsnotify/pull/610
[#617]: https://github.com/fsnotify/fsnotify/pull/617
[#619]: https://github.com/fsnotify/fsnotify/pull/619
[#620]: https://github.com/fsnotify/fsnotify/pull/620
[#621]: https://github.com/fsnotify/fsnotify/pull/621
[#625]: https://github.com/fsnotify/fsnotify/pull/625
[#650]: https://github.com/fsnotify/fsnotify/pull/650
1.7.0 - 2023-10-22
------------------
This version of fsnotify needs Go 1.17.
### Additions
- illumos: add FEN backend to support illumos and Solaris. ([#371])
- all: add `NewBufferedWatcher()` to use a buffered channel, which can be useful
in cases where you can't control the kernel buffer and receive a large number
of events in bursts. ([#550], [#572])
- all: add `AddWith()`, which is identical to `Add()` but allows passing
options. ([#521])
- windows: allow setting the ReadDirectoryChangesW() buffer size with
`fsnotify.WithBufferSize()`; the default of 64K is the highest value that
works on all platforms and is enough for most purposes, but in some cases a
highest buffer is needed. ([#521])
### Changes and fixes
- inotify: remove watcher if a watched path is renamed ([#518])
After a rename the reported name wasn't updated, or even an empty string.
Inotify doesn't provide any good facilities to update it, so just remove the
watcher. This is already how it worked on kqueue and FEN.
On Windows this does work, and remains working.
- windows: don't listen for file attribute changes ([#520])
File attribute changes are sent as `FILE_ACTION_MODIFIED` by the Windows API,
with no way to see if they're a file write or attribute change, so would show
up as a fsnotify.Write event. This is never useful, and could result in many
spurious Write events.
- windows: return `ErrEventOverflow` if the buffer is full ([#525])
Before it would merely return "short read", making it hard to detect this
error.
- kqueue: make sure events for all files are delivered properly when removing a
watched directory ([#526])
Previously they would get sent with `""` (empty string) or `"."` as the path
name.
- kqueue: don't emit spurious Create events for symbolic links ([#524])
The link would get resolved but kqueue would "forget" it already saw the link
itself, resulting on a Create for every Write event for the directory.
- all: return `ErrClosed` on `Add()` when the watcher is closed ([#516])
- other: add `Watcher.Errors` and `Watcher.Events` to the no-op `Watcher` in
`backend_other.go`, making it easier to use on unsupported platforms such as
WASM, AIX, etc. ([#528])
- other: use the `backend_other.go` no-op if the `appengine` build tag is set;
Google AppEngine forbids usage of the unsafe package so the inotify backend
won't compile there.
[#371]: https://github.com/fsnotify/fsnotify/pull/371
[#516]: https://github.com/fsnotify/fsnotify/pull/516
[#518]: https://github.com/fsnotify/fsnotify/pull/518
[#520]: https://github.com/fsnotify/fsnotify/pull/520
[#521]: https://github.com/fsnotify/fsnotify/pull/521
[#524]: https://github.com/fsnotify/fsnotify/pull/524
[#525]: https://github.com/fsnotify/fsnotify/pull/525
[#526]: https://github.com/fsnotify/fsnotify/pull/526
[#528]: https://github.com/fsnotify/fsnotify/pull/528
[#537]: https://github.com/fsnotify/fsnotify/pull/537
[#550]: https://github.com/fsnotify/fsnotify/pull/550
[#572]: https://github.com/fsnotify/fsnotify/pull/572
1.6.0 - 2022-10-13
------------------
This version of fsnotify needs Go 1.16 (this was already the case since 1.5.1,
but not documented). It also increases the minimum Linux version to 2.6.32.
### Additions
- all: add `Event.Has()` and `Op.Has()` ([#477])
This makes checking events a lot easier; for example:
if event.Op&Write == Write && !(event.Op&Remove == Remove) {
}
Becomes:
if event.Has(Write) && !event.Has(Remove) {
}
- all: add cmd/fsnotify ([#463])
A command-line utility for testing and some examples.
### Changes and fixes
- inotify: don't ignore events for files that don't exist ([#260], [#470])
Previously the inotify watcher would call `os.Lstat()` to check if a file
still exists before emitting events.
This was inconsistent with other platforms and resulted in inconsistent event
reporting (e.g. when a file is quickly removed and re-created), and generally
a source of confusion. It was added in 2013 to fix a memory leak that no
longer exists.
- all: return `ErrNonExistentWatch` when `Remove()` is called on a path that's
not watched ([#460])
- inotify: replace epoll() with non-blocking inotify ([#434])
Non-blocking inotify was not generally available at the time this library was
written in 2014, but now it is. As a result, the minimum Linux version is
bumped from 2.6.27 to 2.6.32. This hugely simplifies the code and is faster.
- kqueue: don't check for events every 100ms ([#480])
The watcher would wake up every 100ms, even when there was nothing to do. Now
it waits until there is something to do.
- macos: retry opening files on EINTR ([#475])
- kqueue: skip unreadable files ([#479])
kqueue requires a file descriptor for every file in a directory; this would
fail if a file was unreadable by the current user. Now these files are simply
skipped.
- windows: fix renaming a watched directory if the parent is also watched ([#370])
- windows: increase buffer size from 4K to 64K ([#485])
- windows: close file handle on Remove() ([#288])
- kqueue: put pathname in the error if watching a file fails ([#471])
- inotify, windows: calling Close() more than once could race ([#465])
- kqueue: improve Close() performance ([#233])
- all: various documentation additions and clarifications.
[#233]: https://github.com/fsnotify/fsnotify/pull/233
[#260]: https://github.com/fsnotify/fsnotify/pull/260
[#288]: https://github.com/fsnotify/fsnotify/pull/288
[#370]: https://github.com/fsnotify/fsnotify/pull/370
[#434]: https://github.com/fsnotify/fsnotify/pull/434
[#460]: https://github.com/fsnotify/fsnotify/pull/460
[#463]: https://github.com/fsnotify/fsnotify/pull/463
[#465]: https://github.com/fsnotify/fsnotify/pull/465
[#470]: https://github.com/fsnotify/fsnotify/pull/470
[#471]: https://github.com/fsnotify/fsnotify/pull/471
[#475]: https://github.com/fsnotify/fsnotify/pull/475
[#477]: https://github.com/fsnotify/fsnotify/pull/477
[#479]: https://github.com/fsnotify/fsnotify/pull/479
[#480]: https://github.com/fsnotify/fsnotify/pull/480
[#485]: https://github.com/fsnotify/fsnotify/pull/485
## [1.5.4] - 2022-04-25
* Windows: add missing defer to `Watcher.WatchList` [#447](https://github.com/fsnotify/fsnotify/pull/447)
* go.mod: use latest x/sys [#444](https://github.com/fsnotify/fsnotify/pull/444)
* Fix compilation for OpenBSD [#443](https://github.com/fsnotify/fsnotify/pull/443)
## [1.5.3] - 2022-04-22
* This version is retracted. An incorrect branch is published accidentally [#445](https://github.com/fsnotify/fsnotify/issues/445)
## [1.5.2] - 2022-04-21
* Add a feature to return the directories and files that are being monitored [#374](https://github.com/fsnotify/fsnotify/pull/374)
* Fix potential crash on windows if `raw.FileNameLength` exceeds `syscall.MAX_PATH` [#361](https://github.com/fsnotify/fsnotify/pull/361)
* Allow build on unsupported GOOS [#424](https://github.com/fsnotify/fsnotify/pull/424)
* Don't set `poller.fd` twice in `newFdPoller` [#406](https://github.com/fsnotify/fsnotify/pull/406)
* fix go vet warnings: call to `(*T).Fatalf` from a non-test goroutine [#416](https://github.com/fsnotify/fsnotify/pull/416)
## [1.5.1] - 2021-08-24
* Revert Add AddRaw to not follow symlinks [#394](https://github.com/fsnotify/fsnotify/pull/394)
## [1.5.0] - 2021-08-20
* Go: Increase minimum required version to Go 1.12 [#381](https://github.com/fsnotify/fsnotify/pull/381)
* Feature: Add AddRaw method which does not follow symlinks when adding a watch [#289](https://github.com/fsnotify/fsnotify/pull/298)
* Windows: Follow symlinks by default like on all other systems [#289](https://github.com/fsnotify/fsnotify/pull/289)
* CI: Use GitHub Actions for CI and cover go 1.12-1.17
[#378](https://github.com/fsnotify/fsnotify/pull/378)
[#381](https://github.com/fsnotify/fsnotify/pull/381)
[#385](https://github.com/fsnotify/fsnotify/pull/385)
* Go 1.14+: Fix unsafe pointer conversion [#325](https://github.com/fsnotify/fsnotify/pull/325)
## [1.4.9] - 2020-03-11
* Move example usage to the readme #329. This may resolve #328.
## [1.4.8] - 2020-03-10
* CI: test more go versions (@nathany 1d13583d846ea9d66dcabbfefbfb9d8e6fb05216)
* Tests: Queued inotify events could have been read by the test before max_queued_events was hit (@matthias-stone #265)
* Tests: t.Fatalf -> t.Errorf in go routines (@gdey #266)
* CI: Less verbosity (@nathany #267)
* Tests: Darwin: Exchangedata is deprecated on 10.13 (@nathany #267)
* Tests: Check if channels are closed in the example (@alexeykazakov #244)
* CI: Only run golint on latest version of go and fix issues (@cpuguy83 #284)
* CI: Add windows to travis matrix (@cpuguy83 #284)
* Docs: Remover appveyor badge (@nathany 11844c0959f6fff69ba325d097fce35bd85a8e93)
* Linux: create epoll and pipe fds with close-on-exec (@JohannesEbke #219)
* Linux: open files with close-on-exec (@linxiulei #273)
* Docs: Plan to support fanotify (@nathany ab058b44498e8b7566a799372a39d150d9ea0119 )
* Project: Add go.mod (@nathany #309)
* Project: Revise editor config (@nathany #309)
* Project: Update copyright for 2019 (@nathany #309)
* CI: Drop go1.8 from CI matrix (@nathany #309)
* Docs: Updating the FAQ section for supportability with NFS & FUSE filesystems (@Pratik32 4bf2d1fec78374803a39307bfb8d340688f4f28e )
## [1.4.7] - 2018-01-09
* BSD/macOS: Fix possible deadlock on closing the watcher on kqueue (thanks @nhooyr and @glycerine)
* Tests: Fix missing verb on format string (thanks @rchiossi)
* Linux: Fix deadlock in Remove (thanks @aarondl)
* Linux: Watch.Add improvements (avoid race, fix consistency, reduce garbage) (thanks @twpayne)
* Docs: Moved FAQ into the README (thanks @vahe)
* Linux: Properly handle inotify's IN_Q_OVERFLOW event (thanks @zeldovich)
* Docs: replace references to OS X with macOS
## [1.4.2] - 2016-10-10
* Linux: use InotifyInit1 with IN_CLOEXEC to stop leaking a file descriptor to a child process when using fork/exec [#178](https://github.com/fsnotify/fsnotify/pull/178) (thanks @pattyshack)
## [1.4.1] - 2016-10-04
* Fix flaky inotify stress test on Linux [#177](https://github.com/fsnotify/fsnotify/pull/177) (thanks @pattyshack)
## [1.4.0] - 2016-10-01
* add a String() method to Event.Op [#165](https://github.com/fsnotify/fsnotify/pull/165) (thanks @oozie)
## [1.3.1] - 2016-06-28
* Windows: fix for double backslash when watching the root of a drive [#151](https://github.com/fsnotify/fsnotify/issues/151) (thanks @brunoqc)
## [1.3.0] - 2016-04-19
* Support linux/arm64 by [patching](https://go-review.googlesource.com/#/c/21971/) x/sys/unix and switching to to it from syscall (thanks @suihkulokki) [#135](https://github.com/fsnotify/fsnotify/pull/135)
## [1.2.10] - 2016-03-02
* Fix golint errors in windows.go [#121](https://github.com/fsnotify/fsnotify/pull/121) (thanks @tiffanyfj)
## [1.2.9] - 2016-01-13
kqueue: Fix logic for CREATE after REMOVE [#111](https://github.com/fsnotify/fsnotify/pull/111) (thanks @bep)
## [1.2.8] - 2015-12-17
* kqueue: fix race condition in Close [#105](https://github.com/fsnotify/fsnotify/pull/105) (thanks @djui for reporting the issue and @ppknap for writing a failing test)
* inotify: fix race in test
* enable race detection for continuous integration (Linux, Mac, Windows)
## [1.2.5] - 2015-10-17
* inotify: use epoll_create1 for arm64 support (requires Linux 2.6.27 or later) [#100](https://github.com/fsnotify/fsnotify/pull/100) (thanks @suihkulokki)
* inotify: fix path leaks [#73](https://github.com/fsnotify/fsnotify/pull/73) (thanks @chamaken)
* kqueue: watch for rename events on subdirectories [#83](https://github.com/fsnotify/fsnotify/pull/83) (thanks @guotie)
* kqueue: avoid infinite loops from symlinks cycles [#101](https://github.com/fsnotify/fsnotify/pull/101) (thanks @illicitonion)
## [1.2.1] - 2015-10-14
* kqueue: don't watch named pipes [#98](https://github.com/fsnotify/fsnotify/pull/98) (thanks @evanphx)
## [1.2.0] - 2015-02-08
* inotify: use epoll to wake up readEvents [#66](https://github.com/fsnotify/fsnotify/pull/66) (thanks @PieterD)
* inotify: closing watcher should now always shut down goroutine [#63](https://github.com/fsnotify/fsnotify/pull/63) (thanks @PieterD)
* kqueue: close kqueue after removing watches, fixes [#59](https://github.com/fsnotify/fsnotify/issues/59)
## [1.1.1] - 2015-02-05
* inotify: Retry read on EINTR [#61](https://github.com/fsnotify/fsnotify/issues/61) (thanks @PieterD)
## [1.1.0] - 2014-12-12
* kqueue: rework internals [#43](https://github.com/fsnotify/fsnotify/pull/43)
* add low-level functions
* only need to store flags on directories
* less mutexes [#13](https://github.com/fsnotify/fsnotify/issues/13)
* done can be an unbuffered channel
* remove calls to os.NewSyscallError
* More efficient string concatenation for Event.String() [#52](https://github.com/fsnotify/fsnotify/pull/52) (thanks @mdlayher)
* kqueue: fix regression in rework causing subdirectories to be watched [#48](https://github.com/fsnotify/fsnotify/issues/48)
* kqueue: cleanup internal watch before sending remove event [#51](https://github.com/fsnotify/fsnotify/issues/51)
## [1.0.4] - 2014-09-07
* kqueue: add dragonfly to the build tags.
* Rename source code files, rearrange code so exported APIs are at the top.
* Add done channel to example code. [#37](https://github.com/fsnotify/fsnotify/pull/37) (thanks @chenyukang)
## [1.0.3] - 2014-08-19
* [Fix] Windows MOVED_TO now translates to Create like on BSD and Linux. [#36](https://github.com/fsnotify/fsnotify/issues/36)
## [1.0.2] - 2014-08-17
* [Fix] Missing create events on macOS. [#14](https://github.com/fsnotify/fsnotify/issues/14) (thanks @zhsso)
* [Fix] Make ./path and path equivalent. (thanks @zhsso)
## [1.0.0] - 2014-08-15
* [API] Remove AddWatch on Windows, use Add.
* Improve documentation for exported identifiers. [#30](https://github.com/fsnotify/fsnotify/issues/30)
* Minor updates based on feedback from golint.
## dev / 2014-07-09
* Moved to [github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify).
* Use os.NewSyscallError instead of returning errno (thanks @hariharan-uno)
## dev / 2014-07-04
* kqueue: fix incorrect mutex used in Close()
* Update example to demonstrate usage of Op.
## dev / 2014-06-28
* [API] Don't set the Write Op for attribute notifications [#4](https://github.com/fsnotify/fsnotify/issues/4)
* Fix for String() method on Event (thanks Alex Brainman)
* Don't build on Plan 9 or Solaris (thanks @4ad)
## dev / 2014-06-21
* Events channel of type Event rather than *Event.
* [internal] use syscall constants directly for inotify and kqueue.
* [internal] kqueue: rename events to kevents and fileEvent to event.
## dev / 2014-06-19
* Go 1.3+ required on Windows (uses syscall.ERROR_MORE_DATA internally).
* [internal] remove cookie from Event struct (unused).
* [internal] Event struct has the same definition across every OS.
* [internal] remove internal watch and removeWatch methods.
## dev / 2014-06-12
* [API] Renamed Watch() to Add() and RemoveWatch() to Remove().
* [API] Pluralized channel names: Events and Errors.
* [API] Renamed FileEvent struct to Event.
* [API] Op constants replace methods like IsCreate().
## dev / 2014-06-12
* Fix data race on kevent buffer (thanks @tilaks) [#98](https://github.com/howeyc/fsnotify/pull/98)
## dev / 2014-05-23
* [API] Remove current implementation of WatchFlags.
* current implementation doesn't take advantage of OS for efficiency
* provides little benefit over filtering events as they are received, but has extra bookkeeping and mutexes
* no tests for the current implementation
* not fully implemented on Windows [#93](https://github.com/howeyc/fsnotify/issues/93#issuecomment-39285195)
## [0.9.3] - 2014-12-31
* kqueue: cleanup internal watch before sending remove event [#51](https://github.com/fsnotify/fsnotify/issues/51)
## [0.9.2] - 2014-08-17
* [Backport] Fix missing create events on macOS. [#14](https://github.com/fsnotify/fsnotify/issues/14) (thanks @zhsso)
## [0.9.1] - 2014-06-12
* Fix data race on kevent buffer (thanks @tilaks) [#98](https://github.com/howeyc/fsnotify/pull/98)
## [0.9.0] - 2014-01-17
* IsAttrib() for events that only concern a file's metadata [#79][] (thanks @abustany)
* [Fix] kqueue: fix deadlock [#77][] (thanks @cespare)
* [NOTICE] Development has moved to `code.google.com/p/go.exp/fsnotify` in preparation for inclusion in the Go standard library.
## [0.8.12] - 2013-11-13
* [API] Remove FD_SET and friends from Linux adapter
## [0.8.11] - 2013-11-02
* [Doc] Add Changelog [#72][] (thanks @nathany)
* [Doc] Spotlight and double modify events on macOS [#62][] (reported by @paulhammond)
## [0.8.10] - 2013-10-19
* [Fix] kqueue: remove file watches when parent directory is removed [#71][] (reported by @mdwhatcott)
* [Fix] kqueue: race between Close and readEvents [#70][] (reported by @bernerdschaefer)
* [Doc] specify OS-specific limits in README (thanks @debrando)
## [0.8.9] - 2013-09-08
* [Doc] Contributing (thanks @nathany)
* [Doc] update package path in example code [#63][] (thanks @paulhammond)
* [Doc] GoCI badge in README (Linux only) [#60][]
* [Doc] Cross-platform testing with Vagrant [#59][] (thanks @nathany)
## [0.8.8] - 2013-06-17
* [Fix] Windows: handle `ERROR_MORE_DATA` on Windows [#49][] (thanks @jbowtie)
## [0.8.7] - 2013-06-03
* [API] Make syscall flags internal
* [Fix] inotify: ignore event changes
* [Fix] race in symlink test [#45][] (reported by @srid)
* [Fix] tests on Windows
* lower case error messages
## [0.8.6] - 2013-05-23
* kqueue: Use EVT_ONLY flag on Darwin
* [Doc] Update README with full example
## [0.8.5] - 2013-05-09
* [Fix] inotify: allow monitoring of "broken" symlinks (thanks @tsg)
## [0.8.4] - 2013-04-07
* [Fix] kqueue: watch all file events [#40][] (thanks @ChrisBuchholz)
## [0.8.3] - 2013-03-13
* [Fix] inoitfy/kqueue memory leak [#36][] (reported by @nbkolchin)
* [Fix] kqueue: use fsnFlags for watching a directory [#33][] (reported by @nbkolchin)
## [0.8.2] - 2013-02-07
* [Doc] add Authors
* [Fix] fix data races for map access [#29][] (thanks @fsouza)
## [0.8.1] - 2013-01-09
* [Fix] Windows path separators
* [Doc] BSD License
## [0.8.0] - 2012-11-09
* kqueue: directory watching improvements (thanks @vmirage)
* inotify: add `IN_MOVED_TO` [#25][] (requested by @cpisto)
* [Fix] kqueue: deleting watched directory [#24][] (reported by @jakerr)
## [0.7.4] - 2012-10-09
* [Fix] inotify: fixes from https://codereview.appspot.com/5418045/ (ugorji)
* [Fix] kqueue: preserve watch flags when watching for delete [#21][] (reported by @robfig)
* [Fix] kqueue: watch the directory even if it isn't a new watch (thanks @robfig)
* [Fix] kqueue: modify after recreation of file
## [0.7.3] - 2012-09-27
* [Fix] kqueue: watch with an existing folder inside the watched folder (thanks @vmirage)
* [Fix] kqueue: no longer get duplicate CREATE events
## [0.7.2] - 2012-09-01
* kqueue: events for created directories
## [0.7.1] - 2012-07-14
* [Fix] for renaming files
## [0.7.0] - 2012-07-02
* [Feature] FSNotify flags
* [Fix] inotify: Added file name back to event path
## [0.6.0] - 2012-06-06
* kqueue: watch files after directory created (thanks @tmc)
## [0.5.1] - 2012-05-22
* [Fix] inotify: remove all watches before Close()
## [0.5.0] - 2012-05-03
* [API] kqueue: return errors during watch instead of sending over channel
* kqueue: match symlink behavior on Linux
* inotify: add `DELETE_SELF` (requested by @taralx)
* [Fix] kqueue: handle EINTR (reported by @robfig)
* [Doc] Godoc example [#1][] (thanks @davecheney)
## [0.4.0] - 2012-03-30
* Go 1 released: build with go tool
* [Feature] Windows support using winfsnotify
* Windows does not have attribute change notifications
* Roll attribute notifications into IsModify
## [0.3.0] - 2012-02-19
* kqueue: add files when watch directory
## [0.2.0] - 2011-12-30
* update to latest Go weekly code
## [0.1.0] - 2011-10-19
* kqueue: add watch on file creation to match inotify
* kqueue: create file event
* inotify: ignore `IN_IGNORED` events
* event String()
* linux: common FileEvent functions
* initial commit
[#79]: https://github.com/howeyc/fsnotify/pull/79
[#77]: https://github.com/howeyc/fsnotify/pull/77
[#72]: https://github.com/howeyc/fsnotify/issues/72
[#71]: https://github.com/howeyc/fsnotify/issues/71
[#70]: https://github.com/howeyc/fsnotify/issues/70
[#63]: https://github.com/howeyc/fsnotify/issues/63
[#62]: https://github.com/howeyc/fsnotify/issues/62
[#60]: https://github.com/howeyc/fsnotify/issues/60
[#59]: https://github.com/howeyc/fsnotify/issues/59
[#49]: https://github.com/howeyc/fsnotify/issues/49
[#45]: https://github.com/howeyc/fsnotify/issues/45
[#40]: https://github.com/howeyc/fsnotify/issues/40
[#36]: https://github.com/howeyc/fsnotify/issues/36
[#33]: https://github.com/howeyc/fsnotify/issues/33
[#29]: https://github.com/howeyc/fsnotify/issues/29
[#25]: https://github.com/howeyc/fsnotify/issues/25
[#24]: https://github.com/howeyc/fsnotify/issues/24
[#21]: https://github.com/howeyc/fsnotify/issues/21
+145
View File
@@ -0,0 +1,145 @@
Thank you for your interest in contributing to fsnotify! We try to review and
merge PRs in a reasonable timeframe, but please be aware that:
- To avoid "wasted" work, please discuss changes on the issue tracker first. You
can just send PRs, but they may end up being rejected for one reason or the
other.
- fsnotify is a cross-platform library, and changes must work reasonably well on
all supported platforms.
- Changes will need to be compatible; old code should still compile, and the
runtime behaviour can't change in ways that are likely to lead to problems for
users.
Testing
-------
Just `go test ./...` runs all the tests; the CI runs this on all supported
platforms. Testing different platforms locally can be done with something like
[goon] or [Vagrant], but this isn't super-easy to set up at the moment.
Use the `-short` flag to make the "stress test" run faster.
Writing new tests
-----------------
Scripts in the testdata directory allow creating test cases in a "shell-like"
syntax. The basic format is:
script
Output:
desired output
For example:
# Create a new empty file with some data.
watch /
echo data >/file
Output:
create /file
write /file
Just create a new file to add a new test; select which tests to run with
`-run TestScript/[path]`.
script
------
The script is a "shell-like" script:
cmd arg arg
Comments are supported with `#`:
# Comment
cmd arg arg # Comment
All operations are done in a temp directory; a path like "/foo" is rewritten to
"/tmp/TestFoo/foo".
Arguments can be quoted with `"` or `'`; there are no escapes and they're
functionally identical right now, but this may change in the future, so best to
assume shell-like rules.
touch "/file with spaces"
End-of-line escapes with `\` are not supported.
### Supported commands
watch path [ops] # Watch the path, reporting events for it. Nothing is
# watched by default. Optionally a list of ops can be
# given, as with AddWith(path, WithOps(...)).
unwatch path # Stop watching the path.
watchlist n # Assert watchlist length.
stop # Stop running the script; for debugging.
debug [yes/no] # Enable/disable FSNOTIFY_DEBUG (tests are run in
parallel by default, so -parallel=1 is probably a good
idea).
print [any strings] # Print text to stdout; for debugging.
touch path
mkdir [-p] dir
ln -s target link # Only ln -s supported.
mkfifo path
mknod dev path
mv src dst
rm [-r] path
chmod mode path # Octal only
sleep time-in-ms
cat path # Read path (does nothing with the data; just reads it).
echo str >>path # Append "str" to "path".
echo str >path # Truncate "path" and write "str".
require reason # Skip the test if "reason" is true; "skip" and
skip reason # "require" behave identical; it supports both for
# readability. Possible reasons are:
#
# always Always skip this test.
# symlink Symlinks are supported (requires admin
# permissions on Windows).
# mkfifo Platform doesn't support FIFO named sockets.
# mknod Platform doesn't support device nodes.
output
------
After `Output:` the desired output is given; this is indented by convention, but
that's not required.
The format of that is:
# Comment
event path # Comment
system:
event path
system2:
event path
Every event is one line, and any whitespace between the event and path are
ignored. The path can optionally be surrounded in ". Anything after a "#" is
ignored.
Platform-specific tests can be added after GOOS; for example:
watch /
touch /file
Output:
# Tested if nothing else matches
create /file
# Windows-specific test.
windows:
write /file
You can specify multiple platforms with a comma (e.g. "windows, linux:").
"kqueue" is a shortcut for all kqueue systems (BSD, macOS).
[goon]: https://github.com/arp242/goon
[Vagrant]: https://www.vagrantup.com/
[integration_test.go]: /integration_test.go
+25
View File
@@ -0,0 +1,25 @@
Copyright © 2012 The Go Authors. All rights reserved.
Copyright © fsnotify 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 Google Inc. nor the names of its 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 OWNER 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.
+182
View File
@@ -0,0 +1,182 @@
fsnotify is a Go library to provide cross-platform filesystem notifications on
Windows, Linux, macOS, BSD, and illumos.
Go 1.17 or newer is required; the full documentation is at
https://pkg.go.dev/github.com/fsnotify/fsnotify
---
Platform support:
| Backend | OS | Status |
| :-------------------- | :--------- | :------------------------------------------------------------------------ |
| inotify | Linux | Supported |
| kqueue | BSD, macOS | Supported |
| ReadDirectoryChangesW | Windows | Supported |
| FEN | illumos | Supported |
| fanotify | Linux 5.9+ | [Not yet](https://github.com/fsnotify/fsnotify/issues/114) |
| FSEvents | macOS | [Needs support in x/sys/unix][fsevents] |
| USN Journals | Windows | [Needs support in x/sys/windows][usn] |
| Polling | *All* | [Not yet](https://github.com/fsnotify/fsnotify/issues/9) |
Linux and illumos should include Android and Solaris, but these are currently
untested.
[fsevents]: https://github.com/fsnotify/fsnotify/issues/11#issuecomment-1279133120
[usn]: https://github.com/fsnotify/fsnotify/issues/53#issuecomment-1279829847
Usage
-----
A basic example:
```go
package main
import (
"log"
"github.com/fsnotify/fsnotify"
)
func main() {
// Create new watcher.
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer watcher.Close()
// Start listening for events.
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
log.Println("event:", event)
if event.Has(fsnotify.Write) {
log.Println("modified file:", event.Name)
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("error:", err)
}
}
}()
// Add a path.
err = watcher.Add("/tmp")
if err != nil {
log.Fatal(err)
}
// Block main goroutine forever.
<-make(chan struct{})
}
```
Some more examples can be found in [cmd/fsnotify](cmd/fsnotify), which can be
run with:
% go run ./cmd/fsnotify
Further detailed documentation can be found in godoc:
https://pkg.go.dev/github.com/fsnotify/fsnotify
FAQ
---
### Will a file still be watched when it's moved to another directory?
No, not unless you are watching the location it was moved to.
### Are subdirectories watched?
No, you must add watches for any directory you want to watch (a recursive
watcher is on the roadmap: [#18]).
[#18]: https://github.com/fsnotify/fsnotify/issues/18
### Do I have to watch the Error and Event channels in a goroutine?
Yes. You can read both channels in the same goroutine using `select` (you don't
need a separate goroutine for both channels; see the example).
### Why don't notifications work with NFS, SMB, FUSE, /proc, or /sys?
fsnotify requires support from underlying OS to work. The current NFS and SMB
protocols does not provide network level support for file notifications, and
neither do the /proc and /sys virtual filesystems.
This could be fixed with a polling watcher ([#9]), but it's not yet implemented.
[#9]: https://github.com/fsnotify/fsnotify/issues/9
### Why do I get many Chmod events?
Some programs may generate a lot of attribute changes; for example Spotlight on
macOS, anti-virus programs, backup applications, and some others are known to do
this. As a rule, it's typically best to ignore Chmod events. They're often not
useful, and tend to cause problems.
Spotlight indexing on macOS can result in multiple events (see [#15]). A
temporary workaround is to add your folder(s) to the *Spotlight Privacy
settings* until we have a native FSEvents implementation (see [#11]).
[#11]: https://github.com/fsnotify/fsnotify/issues/11
[#15]: https://github.com/fsnotify/fsnotify/issues/15
### Watching a file doesn't work well
Watching individual files (rather than directories) is generally not recommended
as many programs (especially editors) update files atomically: it will write to
a temporary file which is then moved to to destination, overwriting the original
(or some variant thereof). The watcher on the original file is now lost, as that
no longer exists.
The upshot of this is that a power failure or crash won't leave a half-written
file.
Watch the parent directory and use `Event.Name` to filter out files you're not
interested in. There is an example of this in `cmd/fsnotify/file.go`.
Platform-specific notes
-----------------------
### Linux
When a file is removed a REMOVE event won't be emitted until all file
descriptors are closed; it will emit a CHMOD instead:
fp := os.Open("file")
os.Remove("file") // CHMOD
fp.Close() // REMOVE
This is the event that inotify sends, so not much can be changed about this.
The `fs.inotify.max_user_watches` sysctl variable specifies the upper limit for
the number of watches per user, and `fs.inotify.max_user_instances` specifies
the maximum number of inotify instances per user. Every Watcher you create is an
"instance", and every path you add is a "watch".
These are also exposed in `/proc` as `/proc/sys/fs/inotify/max_user_watches` and
`/proc/sys/fs/inotify/max_user_instances`
To increase them you can use `sysctl` or write the value to proc file:
# The default values on Linux 5.18
sysctl fs.inotify.max_user_watches=124983
sysctl fs.inotify.max_user_instances=128
To make the changes persist on reboot edit `/etc/sysctl.conf` or
`/usr/lib/sysctl.d/50-default.conf` (details differ per Linux distro; check your
distro's documentation):
fs.inotify.max_user_watches=124983
fs.inotify.max_user_instances=128
Reaching the limit will result in a "no space left on device" or "too many open
files" error.
### kqueue (macOS, all BSD systems)
kqueue requires opening a file descriptor for every file that's being watched;
so if you're watching a directory with five files then that's six file
descriptors. You will run in to your system's "max open files" limit faster on
these platforms.
The sysctl variables `kern.maxfiles` and `kern.maxfilesperproc` can be used to
control the maximum number of open files.
+467
View File
@@ -0,0 +1,467 @@
//go:build solaris
// FEN backend for illumos (supported) and Solaris (untested, but should work).
//
// See port_create(3c) etc. for docs. https://www.illumos.org/man/3C/port_create
package fsnotify
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
"time"
"github.com/fsnotify/fsnotify/internal"
"golang.org/x/sys/unix"
)
type fen struct {
*shared
Events chan Event
Errors chan error
mu sync.Mutex
port *unix.EventPort
dirs map[string]Op // Explicitly watched directories
watches map[string]Op // Explicitly watched non-directories
}
var defaultBufferSize = 0
func newBackend(ev chan Event, errs chan error) (backend, error) {
w := &fen{
shared: newShared(ev, errs),
Events: ev,
Errors: errs,
dirs: make(map[string]Op),
watches: make(map[string]Op),
}
var err error
w.port, err = unix.NewEventPort()
if err != nil {
return nil, fmt.Errorf("fsnotify.NewWatcher: %w", err)
}
go w.readEvents()
return w, nil
}
func (w *fen) Close() error {
if w.shared.close() {
return nil
}
return w.port.Close()
}
func (w *fen) Add(name string) error { return w.AddWith(name) }
func (w *fen) AddWith(name string, opts ...addOpt) error {
if w.isClosed() {
return ErrClosed
}
if debug {
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s AddWith(%q)\n",
time.Now().Format("15:04:05.000000000"), name)
}
with := getOptions(opts...)
if !w.xSupports(with.op) {
return fmt.Errorf("%w: %s", xErrUnsupported, with.op)
}
// Currently we resolve symlinks that were explicitly requested to be
// watched. Otherwise we would use LStat here.
stat, err := os.Stat(name)
if err != nil {
return err
}
// Associate all files in the directory.
if stat.IsDir() {
err := w.handleDirectory(name, stat, true, w.associateFile)
if err != nil {
return err
}
w.mu.Lock()
w.dirs[name] = with.op
w.mu.Unlock()
return nil
}
err = w.associateFile(name, stat, true)
if err != nil {
return err
}
w.mu.Lock()
w.watches[name] = with.op
w.mu.Unlock()
return nil
}
func (w *fen) Remove(name string) error {
if w.isClosed() {
return nil
}
if !w.port.PathIsWatched(name) {
return fmt.Errorf("%w: %s", ErrNonExistentWatch, name)
}
if debug {
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s Remove(%q)\n",
time.Now().Format("15:04:05.000000000"), name)
}
// The user has expressed an intent. Immediately remove this name from
// whichever watch list it might be in. If it's not in there the delete
// doesn't cause harm.
w.mu.Lock()
delete(w.watches, name)
delete(w.dirs, name)
w.mu.Unlock()
stat, err := os.Stat(name)
if err != nil {
return err
}
// Remove associations for every file in the directory.
if stat.IsDir() {
err := w.handleDirectory(name, stat, false, w.dissociateFile)
if err != nil {
return err
}
return nil
}
err = w.port.DissociatePath(name)
if err != nil {
return err
}
return nil
}
// readEvents contains the main loop that runs in a goroutine watching for events.
func (w *fen) readEvents() {
// If this function returns, the watcher has been closed and we can close
// these channels
defer func() {
close(w.Errors)
close(w.Events)
}()
pevents := make([]unix.PortEvent, 8)
for {
count, err := w.port.Get(pevents, 1, nil)
if err != nil && err != unix.ETIME {
// Interrupted system call (count should be 0) ignore and continue
if errors.Is(err, unix.EINTR) && count == 0 {
continue
}
// Get failed because we called w.Close()
if errors.Is(err, unix.EBADF) && w.isClosed() {
return
}
// There was an error not caused by calling w.Close()
if !w.sendError(fmt.Errorf("port.Get: %w", err)) {
return
}
}
p := pevents[:count]
for _, pevent := range p {
if pevent.Source != unix.PORT_SOURCE_FILE {
// Event from unexpected source received; should never happen.
if !w.sendError(errors.New("Event from unexpected source received")) {
return
}
continue
}
if debug {
internal.Debug(pevent.Path, pevent.Events)
}
err = w.handleEvent(&pevent)
if !w.sendError(err) {
return
}
}
}
}
func (w *fen) handleDirectory(path string, stat os.FileInfo, follow bool, handler func(string, os.FileInfo, bool) error) error {
files, err := os.ReadDir(path)
if err != nil {
return err
}
// Handle all children of the directory.
for _, entry := range files {
finfo, err := entry.Info()
if err != nil {
return err
}
err = handler(filepath.Join(path, finfo.Name()), finfo, false)
if err != nil {
return err
}
}
// And finally handle the directory itself.
return handler(path, stat, follow)
}
// handleEvent might need to emit more than one fsnotify event if the events
// bitmap matches more than one event type (e.g. the file was both modified and
// had the attributes changed between when the association was created and the
// when event was returned)
func (w *fen) handleEvent(event *unix.PortEvent) error {
var (
events = event.Events
path = event.Path
fmode = event.Cookie.(os.FileMode)
reRegister = true
)
w.mu.Lock()
_, watchedDir := w.dirs[path]
_, watchedPath := w.watches[path]
w.mu.Unlock()
isWatched := watchedDir || watchedPath
if events&unix.FILE_DELETE != 0 {
if !w.sendEvent(Event{Name: path, Op: Remove}) {
return nil
}
reRegister = false
}
if events&unix.FILE_RENAME_FROM != 0 {
if !w.sendEvent(Event{Name: path, Op: Rename}) {
return nil
}
// Don't keep watching the new file name
reRegister = false
}
if events&unix.FILE_RENAME_TO != 0 {
// We don't report a Rename event for this case, because Rename events
// are interpreted as referring to the _old_ name of the file, and in
// this case the event would refer to the new name of the file. This
// type of rename event is not supported by fsnotify.
// inotify reports a Remove event in this case, so we simulate this
// here.
if !w.sendEvent(Event{Name: path, Op: Remove}) {
return nil
}
// Don't keep watching the file that was removed
reRegister = false
}
// The file is gone, nothing left to do.
if !reRegister {
if watchedDir {
w.mu.Lock()
delete(w.dirs, path)
w.mu.Unlock()
}
if watchedPath {
w.mu.Lock()
delete(w.watches, path)
w.mu.Unlock()
}
return nil
}
// If we didn't get a deletion the file still exists and we're going to have
// to watch it again. Let's Stat it now so that we can compare permissions
// and have what we need to continue watching the file
stat, err := os.Lstat(path)
if err != nil {
// This is unexpected, but we should still emit an event. This happens
// most often on "rm -r" of a subdirectory inside a watched directory We
// get a modify event of something happening inside, but by the time we
// get here, the sudirectory is already gone. Clearly we were watching
// this path but now it is gone. Let's tell the user that it was
// removed.
if !w.sendEvent(Event{Name: path, Op: Remove}) {
return nil
}
// Suppress extra write events on removed directories; they are not
// informative and can be confusing.
return nil
}
// resolve symlinks that were explicitly watched as we would have at Add()
// time. this helps suppress spurious Chmod events on watched symlinks
if isWatched {
stat, err = os.Stat(path)
if err != nil {
// The symlink still exists, but the target is gone. Report the
// Remove similar to above.
if !w.sendEvent(Event{Name: path, Op: Remove}) {
return nil
}
// Don't return the error
}
}
if events&unix.FILE_MODIFIED != 0 {
if fmode.IsDir() && watchedDir {
if err := w.updateDirectory(path); err != nil {
return err
}
} else {
if !w.sendEvent(Event{Name: path, Op: Write}) {
return nil
}
}
}
if events&unix.FILE_ATTRIB != 0 && stat != nil {
// Only send Chmod if perms changed
if stat.Mode().Perm() != fmode.Perm() {
if !w.sendEvent(Event{Name: path, Op: Chmod}) {
return nil
}
}
}
if stat != nil {
// If we get here, it means we've hit an event above that requires us to
// continue watching the file or directory
err := w.associateFile(path, stat, isWatched)
if errors.Is(err, fs.ErrNotExist) {
// Path may have been removed since the stat.
err = nil
}
return err
}
return nil
}
// The directory was modified, so we must find unwatched entities and watch
// them. If something was removed from the directory, nothing will happen, as
// everything else should still be watched.
func (w *fen) updateDirectory(path string) error {
files, err := os.ReadDir(path)
if err != nil {
// Directory no longer exists: probably just deleted since we got the
// event.
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
for _, entry := range files {
path := filepath.Join(path, entry.Name())
if w.port.PathIsWatched(path) {
continue
}
finfo, err := entry.Info()
if err != nil {
return err
}
err = w.associateFile(path, finfo, false)
if errors.Is(err, fs.ErrNotExist) {
// File may have disappeared between getting the dir listing and
// adding the port: that's okay to ignore.
continue
}
if !w.sendError(err) {
return nil
}
if !w.sendEvent(Event{Name: path, Op: Create}) {
return nil
}
}
return nil
}
func (w *fen) associateFile(path string, stat os.FileInfo, follow bool) error {
if w.isClosed() {
return ErrClosed
}
// This is primarily protecting the call to AssociatePath but it is
// important and intentional that the call to PathIsWatched is also
// protected by this mutex. Without this mutex, AssociatePath has been seen
// to error out that the path is already associated.
w.mu.Lock()
defer w.mu.Unlock()
if w.port.PathIsWatched(path) {
// Remove the old association in favor of this one If we get ENOENT,
// then while the x/sys/unix wrapper still thought that this path was
// associated, the underlying event port did not. This call will have
// cleared up that discrepancy. The most likely cause is that the event
// has fired but we haven't processed it yet.
err := w.port.DissociatePath(path)
if err != nil && !errors.Is(err, unix.ENOENT) {
return fmt.Errorf("port.DissociatePath(%q): %w", path, err)
}
}
var events int
if !follow {
// Watch symlinks themselves rather than their targets unless this entry
// is explicitly watched.
events |= unix.FILE_NOFOLLOW
}
if true { // TODO: implement withOps()
events |= unix.FILE_MODIFIED
}
if true {
events |= unix.FILE_ATTRIB
}
err := w.port.AssociatePath(path, stat, events, stat.Mode())
if err != nil {
return fmt.Errorf("port.AssociatePath(%q): %w", path, err)
}
return nil
}
func (w *fen) dissociateFile(path string, stat os.FileInfo, unused bool) error {
if !w.port.PathIsWatched(path) {
return nil
}
err := w.port.DissociatePath(path)
if err != nil {
return fmt.Errorf("port.DissociatePath(%q): %w", path, err)
}
return nil
}
func (w *fen) WatchList() []string {
if w.isClosed() {
return nil
}
w.mu.Lock()
defer w.mu.Unlock()
entries := make([]string, 0, len(w.watches)+len(w.dirs))
for pathname := range w.dirs {
entries = append(entries, pathname)
}
for pathname := range w.watches {
entries = append(entries, pathname)
}
return entries
}
func (w *fen) xSupports(op Op) bool {
if op.Has(xUnportableOpen) || op.Has(xUnportableRead) ||
op.Has(xUnportableCloseWrite) || op.Has(xUnportableCloseRead) {
return false
}
return true
}
+583
View File
@@ -0,0 +1,583 @@
//go:build linux && !appengine
package fsnotify
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unsafe"
"github.com/fsnotify/fsnotify/internal"
"golang.org/x/sys/unix"
)
type inotify struct {
*shared
Events chan Event
Errors chan error
// Store fd here as os.File.Read() will no longer return on close after
// calling Fd(). See: https://github.com/golang/go/issues/26439
fd int
inotifyFile *os.File
watches *watches
doneResp chan struct{} // Channel to respond to Close
// Store rename cookies in an array, with the index wrapping to 0. Almost
// all of the time what we get is a MOVED_FROM to set the cookie and the
// next event inotify sends will be MOVED_TO to read it. However, this is
// not guaranteed as described in inotify(7) and we may get other events
// between the two MOVED_* events (including other MOVED_* ones).
//
// A second issue is that moving a file outside the watched directory will
// trigger a MOVED_FROM to set the cookie, but we never see the MOVED_TO to
// read and delete it. So just storing it in a map would slowly leak memory.
//
// Doing it like this gives us a simple fast LRU-cache that won't allocate.
// Ten items should be more than enough for our purpose, and a loop over
// such a short array is faster than a map access anyway (not that it hugely
// matters since we're talking about hundreds of ns at the most, but still).
cookies [10]koekje
cookieIndex uint8
cookiesMu sync.Mutex
}
type (
watches struct {
wd map[uint32]*watch // wd → watch
path map[string]uint32 // pathname → wd
}
watch struct {
wd uint32 // Watch descriptor (as returned by the inotify_add_watch() syscall)
flags uint32 // inotify flags of this watch (see inotify(7) for the list of valid flags)
path string // Watch path.
recurse bool // Recursion with ./...?
}
koekje struct {
cookie uint32
path string
}
)
func newWatches() *watches {
return &watches{
wd: make(map[uint32]*watch),
path: make(map[string]uint32),
}
}
func (w *watches) byPath(path string) *watch { return w.wd[w.path[path]] }
func (w *watches) byWd(wd uint32) *watch { return w.wd[wd] }
func (w *watches) len() int { return len(w.wd) }
func (w *watches) add(ww *watch) { w.wd[ww.wd] = ww; w.path[ww.path] = ww.wd }
func (w *watches) remove(watch *watch) { delete(w.path, watch.path); delete(w.wd, watch.wd) }
func (w *watches) removePath(path string) ([]uint32, error) {
path, recurse := recursivePath(path)
wd, ok := w.path[path]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrNonExistentWatch, path)
}
watch := w.wd[wd]
if recurse && !watch.recurse {
return nil, fmt.Errorf("can't use /... with non-recursive watch %q", path)
}
delete(w.path, path)
delete(w.wd, wd)
if !watch.recurse {
return []uint32{wd}, nil
}
wds := make([]uint32, 0, 8)
wds = append(wds, wd)
for p, rwd := range w.path {
if strings.HasPrefix(p, path) {
delete(w.path, p)
delete(w.wd, rwd)
wds = append(wds, rwd)
}
}
return wds, nil
}
func (w *watches) updatePath(path string, f func(*watch) (*watch, error)) error {
var existing *watch
wd, ok := w.path[path]
if ok {
existing = w.wd[wd]
}
upd, err := f(existing)
if err != nil {
return err
}
if upd != nil {
w.wd[upd.wd] = upd
w.path[upd.path] = upd.wd
if upd.wd != wd {
delete(w.wd, wd)
}
}
return nil
}
var defaultBufferSize = 0
func newBackend(ev chan Event, errs chan error) (backend, error) {
// Need to set nonblocking mode for SetDeadline to work, otherwise blocking
// I/O operations won't terminate on close.
fd, errno := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
if fd == -1 {
return nil, errno
}
w := &inotify{
shared: newShared(ev, errs),
Events: ev,
Errors: errs,
fd: fd,
inotifyFile: os.NewFile(uintptr(fd), ""),
watches: newWatches(),
doneResp: make(chan struct{}),
}
go w.readEvents()
return w, nil
}
func (w *inotify) Close() error {
if w.shared.close() {
return nil
}
// Causes any blocking reads to return with an error, provided the file
// still supports deadline operations.
err := w.inotifyFile.Close()
if err != nil {
return err
}
<-w.doneResp // Wait for readEvents() to finish.
return nil
}
func (w *inotify) Add(name string) error { return w.AddWith(name) }
func (w *inotify) AddWith(path string, opts ...addOpt) error {
if w.isClosed() {
return ErrClosed
}
if debug {
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s AddWith(%q)\n",
time.Now().Format("15:04:05.000000000"), path)
}
with := getOptions(opts...)
if !w.xSupports(with.op) {
return fmt.Errorf("%w: %s", xErrUnsupported, with.op)
}
add := func(path string, with withOpts, recurse bool) error {
var flags uint32
if with.noFollow {
flags |= unix.IN_DONT_FOLLOW
}
if with.op.Has(Create) {
flags |= unix.IN_CREATE
}
if with.op.Has(Write) {
flags |= unix.IN_MODIFY
}
if with.op.Has(Remove) {
flags |= unix.IN_DELETE | unix.IN_DELETE_SELF
}
if with.op.Has(Rename) {
flags |= unix.IN_MOVED_TO | unix.IN_MOVED_FROM | unix.IN_MOVE_SELF
}
if with.op.Has(Chmod) {
flags |= unix.IN_ATTRIB
}
if with.op.Has(xUnportableOpen) {
flags |= unix.IN_OPEN
}
if with.op.Has(xUnportableRead) {
flags |= unix.IN_ACCESS
}
if with.op.Has(xUnportableCloseWrite) {
flags |= unix.IN_CLOSE_WRITE
}
if with.op.Has(xUnportableCloseRead) {
flags |= unix.IN_CLOSE_NOWRITE
}
return w.register(path, flags, recurse)
}
w.mu.Lock()
defer w.mu.Unlock()
path, recurse := recursivePath(path)
if recurse {
return filepath.WalkDir(path, func(root string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
if root == path {
return fmt.Errorf("fsnotify: not a directory: %q", path)
}
return nil
}
// Send a Create event when adding new directory from a recursive
// watch; this is for "mkdir -p one/two/three". Usually all those
// directories will be created before we can set up watchers on the
// subdirectories, so only "one" would be sent as a Create event and
// not "one/two" and "one/two/three" (inotifywait -r has the same
// problem).
if with.sendCreate && root != path {
w.sendEvent(Event{Name: root, Op: Create})
}
return add(root, with, true)
})
}
return add(path, with, false)
}
func (w *inotify) register(path string, flags uint32, recurse bool) error {
return w.watches.updatePath(path, func(existing *watch) (*watch, error) {
if existing != nil {
flags |= existing.flags | unix.IN_MASK_ADD
}
wd, err := unix.InotifyAddWatch(w.fd, path, flags)
if wd == -1 {
return nil, err
}
if e, ok := w.watches.wd[uint32(wd)]; ok {
return e, nil
}
if existing == nil {
return &watch{
wd: uint32(wd),
path: path,
flags: flags,
recurse: recurse,
}, nil
}
existing.wd = uint32(wd)
existing.flags = flags
return existing, nil
})
}
func (w *inotify) Remove(name string) error {
if w.isClosed() {
return nil
}
if debug {
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s Remove(%q)\n",
time.Now().Format("15:04:05.000000000"), name)
}
w.mu.Lock()
defer w.mu.Unlock()
return w.remove(filepath.Clean(name))
}
func (w *inotify) remove(name string) error {
wds, err := w.watches.removePath(name)
if err != nil {
return err
}
for _, wd := range wds {
_, err := unix.InotifyRmWatch(w.fd, wd)
if err != nil {
// TODO: Perhaps it's not helpful to return an error here in every
// case; the only two possible errors are:
//
// EBADF, which happens when w.fd is not a valid file descriptor of
// any kind.
//
// EINVAL, which is when fd is not an inotify descriptor or wd is
// not a valid watch descriptor. Watch descriptors are invalidated
// when they are removed explicitly or implicitly; explicitly by
// inotify_rm_watch, implicitly when the file they are watching is
// deleted.
return err
}
}
return nil
}
func (w *inotify) WatchList() []string {
if w.isClosed() {
return nil
}
w.mu.Lock()
defer w.mu.Unlock()
entries := make([]string, 0, w.watches.len())
for pathname := range w.watches.path {
entries = append(entries, pathname)
}
return entries
}
// readEvents reads from the inotify file descriptor, converts the
// received events into Event objects and sends them via the Events channel
func (w *inotify) readEvents() {
defer func() {
close(w.doneResp)
close(w.Errors)
close(w.Events)
}()
var buf [unix.SizeofInotifyEvent * 4096]byte // Buffer for a maximum of 4096 raw events
for {
if w.isClosed() {
return
}
n, err := w.inotifyFile.Read(buf[:])
if err != nil {
if errors.Is(err, os.ErrClosed) {
return
}
if !w.sendError(err) {
return
}
continue
}
if n < unix.SizeofInotifyEvent {
err := errors.New("notify: short read in readEvents()") // Read was too short.
if n == 0 {
err = io.EOF // If EOF is received. This should really never happen.
}
if !w.sendError(err) {
return
}
continue
}
// We don't know how many events we just read into the buffer While the
// offset points to at least one whole event.
var offset uint32
for offset <= uint32(n-unix.SizeofInotifyEvent) {
// Point to the event in the buffer.
inEvent := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset]))
if inEvent.Mask&unix.IN_Q_OVERFLOW != 0 {
if !w.sendError(ErrEventOverflow) {
return
}
}
ev, ok := w.handleEvent(inEvent, &buf, offset)
if !ok {
return
}
if !w.sendEvent(ev) {
return
}
// Move to the next event in the buffer
offset += unix.SizeofInotifyEvent + inEvent.Len
}
}
}
func (w *inotify) handleEvent(inEvent *unix.InotifyEvent, buf *[65536]byte, offset uint32) (Event, bool) {
w.mu.Lock()
defer w.mu.Unlock()
/// If the event happened to the watched directory or the watched file, the
/// kernel doesn't append the filename to the event, but we would like to
/// always fill the the "Name" field with a valid filename. We retrieve the
/// path of the watch from the "paths" map.
///
/// Can be nil if Remove() was called in another goroutine for this path
/// inbetween reading the events from the kernel and reading the internal
/// state. Not much we can do about it, so just skip. See #616.
watch := w.watches.byWd(uint32(inEvent.Wd))
if watch == nil {
return Event{}, true
}
var (
name = watch.path
nameLen = uint32(inEvent.Len)
)
if nameLen > 0 {
/// Point "bytes" at the first byte of the filename
bb := *buf
bytes := (*[unix.PathMax]byte)(unsafe.Pointer(&bb[offset+unix.SizeofInotifyEvent]))[:nameLen:nameLen]
/// The filename is padded with NULL bytes. TrimRight() gets rid of those.
name += "/" + strings.TrimRight(string(bytes[0:nameLen]), "\x00")
}
if debug {
internal.Debug(name, inEvent.Mask, inEvent.Cookie)
}
if inEvent.Mask&unix.IN_IGNORED != 0 || inEvent.Mask&unix.IN_UNMOUNT != 0 {
w.watches.remove(watch)
return Event{}, true
}
// inotify will automatically remove the watch on deletes; just need
// to clean our state here.
if inEvent.Mask&unix.IN_DELETE_SELF == unix.IN_DELETE_SELF {
w.watches.remove(watch)
}
// We can't really update the state when a watched path is moved; only
// IN_MOVE_SELF is sent and not IN_MOVED_{FROM,TO}. So remove the watch.
if inEvent.Mask&unix.IN_MOVE_SELF == unix.IN_MOVE_SELF {
if watch.recurse { // Do nothing
return Event{}, true
}
err := w.remove(watch.path)
if err != nil && !errors.Is(err, ErrNonExistentWatch) {
if !w.sendError(err) {
return Event{}, false
}
}
}
/// Skip if we're watching both this path and the parent; the parent will
/// already send a delete so no need to do it twice.
if inEvent.Mask&unix.IN_DELETE_SELF != 0 {
_, ok := w.watches.path[filepath.Dir(watch.path)]
if ok {
return Event{}, true
}
}
ev := w.newEvent(name, inEvent.Mask, inEvent.Cookie)
// Need to update watch path for recurse.
if watch.recurse {
isDir := inEvent.Mask&unix.IN_ISDIR == unix.IN_ISDIR
/// New directory created: set up watch on it.
if isDir && ev.Has(Create) {
err := w.register(ev.Name, watch.flags, true)
if !w.sendError(err) {
return Event{}, false
}
// This was a directory rename, so we need to update all the
// children.
//
// TODO: this is of course pretty slow; we should use a better data
// structure for storing all of this, e.g. store children in the
// watch. I have some code for this in my kqueue refactor we can use
// in the future. For now I'm okay with this as it's not publicly
// available. Correctness first, performance second.
if ev.renamedFrom != "" {
for k, ww := range w.watches.wd {
if k == watch.wd || ww.path == ev.Name {
continue
}
if strings.HasPrefix(ww.path, ev.renamedFrom) {
ww.path = strings.Replace(ww.path, ev.renamedFrom, ev.Name, 1)
w.watches.wd[k] = ww
}
}
}
}
}
return ev, true
}
func (w *inotify) isRecursive(path string) bool {
ww := w.watches.byPath(path)
if ww == nil { // path could be a file, so also check the Dir.
ww = w.watches.byPath(filepath.Dir(path))
}
return ww != nil && ww.recurse
}
func (w *inotify) newEvent(name string, mask, cookie uint32) Event {
e := Event{Name: name}
if mask&unix.IN_CREATE == unix.IN_CREATE || mask&unix.IN_MOVED_TO == unix.IN_MOVED_TO {
e.Op |= Create
}
if mask&unix.IN_DELETE_SELF == unix.IN_DELETE_SELF || mask&unix.IN_DELETE == unix.IN_DELETE {
e.Op |= Remove
}
if mask&unix.IN_MODIFY == unix.IN_MODIFY {
e.Op |= Write
}
if mask&unix.IN_OPEN == unix.IN_OPEN {
e.Op |= xUnportableOpen
}
if mask&unix.IN_ACCESS == unix.IN_ACCESS {
e.Op |= xUnportableRead
}
if mask&unix.IN_CLOSE_WRITE == unix.IN_CLOSE_WRITE {
e.Op |= xUnportableCloseWrite
}
if mask&unix.IN_CLOSE_NOWRITE == unix.IN_CLOSE_NOWRITE {
e.Op |= xUnportableCloseRead
}
if mask&unix.IN_MOVE_SELF == unix.IN_MOVE_SELF || mask&unix.IN_MOVED_FROM == unix.IN_MOVED_FROM {
e.Op |= Rename
}
if mask&unix.IN_ATTRIB == unix.IN_ATTRIB {
e.Op |= Chmod
}
if cookie != 0 {
if mask&unix.IN_MOVED_FROM == unix.IN_MOVED_FROM {
w.cookiesMu.Lock()
w.cookies[w.cookieIndex] = koekje{cookie: cookie, path: e.Name}
w.cookieIndex++
if w.cookieIndex > 9 {
w.cookieIndex = 0
}
w.cookiesMu.Unlock()
} else if mask&unix.IN_MOVED_TO == unix.IN_MOVED_TO {
w.cookiesMu.Lock()
var prev string
for _, c := range w.cookies {
if c.cookie == cookie {
prev = c.path
break
}
}
w.cookiesMu.Unlock()
e.renamedFrom = prev
}
}
return e
}
func (w *inotify) xSupports(op Op) bool {
return true // Supports everything.
}
func (w *inotify) state() {
w.mu.Lock()
defer w.mu.Unlock()
for wd, ww := range w.watches.wd {
fmt.Fprintf(os.Stderr, "%4d: recurse=%t %q\n", wd, ww.recurse, ww.path)
}
}
+705
View File
@@ -0,0 +1,705 @@
//go:build freebsd || openbsd || netbsd || dragonfly || darwin
package fsnotify
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/fsnotify/fsnotify/internal"
"golang.org/x/sys/unix"
)
type kqueue struct {
*shared
Events chan Event
Errors chan error
kq int // File descriptor (as returned by the kqueue() syscall).
closepipe [2]int // Pipe used for closing kq.
watches *watches
}
type (
watches struct {
mu sync.RWMutex
wd map[int]watch // wd → watch
path map[string]int // pathname → wd
byDir map[string]map[int]struct{} // dirname(path) → wd
seen map[string]struct{} // Keep track of if we know this file exists.
byUser map[string]struct{} // Watches added with Watcher.Add()
}
watch struct {
wd int
name string
linkName string // In case of links; name is the target, and this is the link.
isDir bool
dirFlags uint32
}
)
func newWatches() *watches {
return &watches{
wd: make(map[int]watch),
path: make(map[string]int),
byDir: make(map[string]map[int]struct{}),
seen: make(map[string]struct{}),
byUser: make(map[string]struct{}),
}
}
func (w *watches) listPaths(userOnly bool) []string {
w.mu.RLock()
defer w.mu.RUnlock()
if userOnly {
l := make([]string, 0, len(w.byUser))
for p := range w.byUser {
l = append(l, p)
}
return l
}
l := make([]string, 0, len(w.path))
for p := range w.path {
l = append(l, p)
}
return l
}
func (w *watches) watchesInDir(path string) []string {
w.mu.RLock()
defer w.mu.RUnlock()
l := make([]string, 0, 4)
for fd := range w.byDir[path] {
info := w.wd[fd]
if _, ok := w.byUser[info.name]; !ok {
l = append(l, info.name)
}
}
return l
}
// Mark path as added by the user.
func (w *watches) addUserWatch(path string) {
w.mu.Lock()
defer w.mu.Unlock()
w.byUser[path] = struct{}{}
}
func (w *watches) addLink(path string, fd int) {
w.mu.Lock()
defer w.mu.Unlock()
w.path[path] = fd
w.seen[path] = struct{}{}
}
func (w *watches) add(path, linkPath string, fd int, isDir bool) {
w.mu.Lock()
defer w.mu.Unlock()
w.path[path] = fd
w.wd[fd] = watch{wd: fd, name: path, linkName: linkPath, isDir: isDir}
parent := filepath.Dir(path)
byDir, ok := w.byDir[parent]
if !ok {
byDir = make(map[int]struct{}, 1)
w.byDir[parent] = byDir
}
byDir[fd] = struct{}{}
}
func (w *watches) byWd(fd int) (watch, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
info, ok := w.wd[fd]
return info, ok
}
func (w *watches) byPath(path string) (watch, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
info, ok := w.wd[w.path[path]]
return info, ok
}
func (w *watches) updateDirFlags(path string, flags uint32) bool {
w.mu.Lock()
defer w.mu.Unlock()
fd, ok := w.path[path]
if !ok { // Already deleted: don't re-set it here.
return false
}
info := w.wd[fd]
info.dirFlags = flags
w.wd[fd] = info
return true
}
func (w *watches) remove(fd int, path string) bool {
w.mu.Lock()
defer w.mu.Unlock()
isDir := w.wd[fd].isDir
delete(w.path, path)
delete(w.byUser, path)
parent := filepath.Dir(path)
delete(w.byDir[parent], fd)
if len(w.byDir[parent]) == 0 {
delete(w.byDir, parent)
}
delete(w.wd, fd)
delete(w.seen, path)
return isDir
}
func (w *watches) markSeen(path string, exists bool) {
w.mu.Lock()
defer w.mu.Unlock()
if exists {
w.seen[path] = struct{}{}
} else {
delete(w.seen, path)
}
}
func (w *watches) seenBefore(path string) bool {
w.mu.RLock()
defer w.mu.RUnlock()
_, ok := w.seen[path]
return ok
}
var defaultBufferSize = 0
func newBackend(ev chan Event, errs chan error) (backend, error) {
kq, closepipe, err := newKqueue()
if err != nil {
return nil, err
}
w := &kqueue{
shared: newShared(ev, errs),
Events: ev,
Errors: errs,
kq: kq,
closepipe: closepipe,
watches: newWatches(),
}
go w.readEvents()
return w, nil
}
// newKqueue creates a new kernel event queue and returns a descriptor.
//
// This registers a new event on closepipe, which will trigger an event when
// it's closed. This way we can use kevent() without timeout/polling; without
// the closepipe, it would block forever and we wouldn't be able to stop it at
// all.
func newKqueue() (kq int, closepipe [2]int, err error) {
kq, err = unix.Kqueue()
if err != nil {
return kq, closepipe, err
}
// Register the close pipe.
err = unix.Pipe(closepipe[:])
if err != nil {
unix.Close(kq)
return kq, closepipe, err
}
unix.CloseOnExec(closepipe[0])
unix.CloseOnExec(closepipe[1])
// Register changes to listen on the closepipe.
changes := make([]unix.Kevent_t, 1)
// SetKevent converts int to the platform-specific types.
unix.SetKevent(&changes[0], closepipe[0], unix.EVFILT_READ,
unix.EV_ADD|unix.EV_ENABLE|unix.EV_ONESHOT)
ok, err := unix.Kevent(kq, changes, nil, nil)
if ok == -1 {
unix.Close(kq)
unix.Close(closepipe[0])
unix.Close(closepipe[1])
return kq, closepipe, err
}
return kq, closepipe, nil
}
func (w *kqueue) Close() error {
if w.shared.close() {
return nil
}
pathsToRemove := w.watches.listPaths(false)
for _, name := range pathsToRemove {
w.Remove(name)
}
unix.Close(w.closepipe[1]) // Send "quit" message to readEvents
return nil
}
func (w *kqueue) Add(name string) error { return w.AddWith(name) }
func (w *kqueue) AddWith(name string, opts ...addOpt) error {
if debug {
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s AddWith(%q)\n",
time.Now().Format("15:04:05.000000000"), name)
}
with := getOptions(opts...)
if !w.xSupports(with.op) {
return fmt.Errorf("%w: %s", xErrUnsupported, with.op)
}
_, err := w.addWatch(name, noteAllEvents, false)
if err != nil {
return err
}
w.watches.addUserWatch(name)
return nil
}
func (w *kqueue) Remove(name string) error {
if debug {
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s Remove(%q)\n",
time.Now().Format("15:04:05.000000000"), name)
}
return w.remove(name, true)
}
func (w *kqueue) remove(name string, unwatchFiles bool) error {
if w.isClosed() {
return nil
}
name = filepath.Clean(name)
info, ok := w.watches.byPath(name)
if !ok {
return fmt.Errorf("%w: %s", ErrNonExistentWatch, name)
}
err := w.register([]int{info.wd}, unix.EV_DELETE, 0)
if err != nil {
return err
}
unix.Close(info.wd)
isDir := w.watches.remove(info.wd, name)
// Find all watched paths that are in this directory that are not external.
if unwatchFiles && isDir {
pathsToRemove := w.watches.watchesInDir(name)
for _, name := range pathsToRemove {
// Since these are internal, not much sense in propagating error to
// the user, as that will just confuse them with an error about a
// path they did not explicitly watch themselves.
w.Remove(name)
}
}
return nil
}
func (w *kqueue) WatchList() []string {
if w.isClosed() {
return nil
}
return w.watches.listPaths(true)
}
// Watch all events (except NOTE_EXTEND, NOTE_LINK, NOTE_REVOKE)
const noteAllEvents = unix.NOTE_DELETE | unix.NOTE_WRITE | unix.NOTE_ATTRIB | unix.NOTE_RENAME
// addWatch adds name to the watched file set; the flags are interpreted as
// described in kevent(2).
//
// Returns the real path to the file which was added, with symlinks resolved.
func (w *kqueue) addWatch(name string, flags uint32, listDir bool) (string, error) {
if w.isClosed() {
return "", ErrClosed
}
name = filepath.Clean(name)
info, alreadyWatching := w.watches.byPath(name)
if !alreadyWatching {
fi, err := os.Lstat(name)
if err != nil {
return "", err
}
// Don't watch sockets or named pipes.
if (fi.Mode()&os.ModeSocket == os.ModeSocket) || (fi.Mode()&os.ModeNamedPipe == os.ModeNamedPipe) {
return "", nil
}
// Follow symlinks, but only for paths added with Add(), and not paths
// we're adding from internalWatch from a listdir.
if !listDir && fi.Mode()&os.ModeSymlink == os.ModeSymlink {
link, err := os.Readlink(name)
if err != nil {
return "", err
}
if !filepath.IsAbs(link) {
link = filepath.Join(filepath.Dir(name), link)
}
_, alreadyWatching = w.watches.byPath(link)
if alreadyWatching {
// Add to watches so we don't get spurious Create events later
// on when we diff the directories.
w.watches.addLink(name, 0)
return link, nil
}
info.linkName = name
name = link
fi, err = os.Lstat(name)
if err != nil {
return "", err
}
}
// Retry on EINTR; open() can return EINTR in practice on macOS.
// See #354, and Go issues 11180 and 39237.
for {
info.wd, err = unix.Open(name, openMode, 0)
if err == nil {
break
}
if errors.Is(err, unix.EINTR) {
continue
}
return "", err
}
info.isDir = fi.IsDir()
}
err := w.register([]int{info.wd}, unix.EV_ADD|unix.EV_CLEAR|unix.EV_ENABLE, flags)
if err != nil {
unix.Close(info.wd)
return "", err
}
if !alreadyWatching {
w.watches.add(name, info.linkName, info.wd, info.isDir)
}
// Watch the directory if it has not been watched before, or if it was
// watched before, but perhaps only a NOTE_DELETE (watchDirectoryFiles)
if info.isDir {
watchDir := (flags&unix.NOTE_WRITE) == unix.NOTE_WRITE &&
(!alreadyWatching || (info.dirFlags&unix.NOTE_WRITE) != unix.NOTE_WRITE)
if !w.watches.updateDirFlags(name, flags) {
return "", nil
}
if watchDir {
d := name
if info.linkName != "" {
d = info.linkName
}
if err := w.watchDirectoryFiles(d); err != nil {
return "", err
}
}
}
return name, nil
}
// readEvents reads from kqueue and converts the received kevents into
// Event values that it sends down the Events channel.
func (w *kqueue) readEvents() {
defer func() {
close(w.Events)
close(w.Errors)
_ = unix.Close(w.kq)
unix.Close(w.closepipe[0])
}()
eventBuffer := make([]unix.Kevent_t, 10)
for {
kevents, err := w.read(eventBuffer)
// EINTR is okay, the syscall was interrupted before timeout expired.
if err != nil && err != unix.EINTR {
if !w.sendError(fmt.Errorf("fsnotify.readEvents: %w", err)) {
return
}
}
for _, kevent := range kevents {
var (
wd = int(kevent.Ident)
mask = uint32(kevent.Fflags)
)
// Shut down the loop when the pipe is closed, but only after all
// other events have been processed.
if wd == w.closepipe[0] {
return
}
path, ok := w.watches.byWd(wd)
if debug {
internal.Debug(path.name, &kevent)
}
// On macOS it seems that sometimes an event with Ident=0 is
// delivered, and no other flags/information beyond that, even
// though we never saw such a file descriptor. For example in
// TestWatchSymlink/277 (usually at the end, but sometimes sooner):
//
// fmt.Printf("READ: %2d %#v\n", kevent.Ident, kevent)
// unix.Kevent_t{Ident:0x2a, Filter:-4, Flags:0x25, Fflags:0x2, Data:0, Udata:(*uint8)(nil)}
// unix.Kevent_t{Ident:0x0, Filter:-4, Flags:0x25, Fflags:0x2, Data:0, Udata:(*uint8)(nil)}
//
// The first is a normal event, the second with Ident 0. No error
// flag, no data, no ... nothing.
//
// I read a bit through bsd/kern_event.c from the xnu source, but I
// don't really see an obvious location where this is triggered
// this doesn't seem intentional, but idk...
//
// Technically fd 0 is a valid descriptor, so only skip it if
// there's no path, and if we're on macOS.
if !ok && kevent.Ident == 0 && runtime.GOOS == "darwin" {
continue
}
event := w.newEvent(path.name, path.linkName, mask)
if event.Has(Rename) || event.Has(Remove) {
w.remove(event.Name, false)
w.watches.markSeen(event.Name, false)
}
if path.isDir && event.Has(Write) && !event.Has(Remove) {
w.dirChange(event.Name)
} else if !w.sendEvent(event) {
return
}
if event.Has(Remove) {
// Look for a file that may have overwritten this; for example,
// mv f1 f2 will delete f2, then create f2.
if path.isDir {
fileDir := filepath.Clean(event.Name)
_, found := w.watches.byPath(fileDir)
if found {
// TODO: this branch is never triggered in any test.
// Added in d6220df (2012).
// isDir check added in 8611c35 (2016): https://github.com/fsnotify/fsnotify/pull/111
//
// I don't really get how this can be triggered either.
// And it wasn't triggered in the patch that added it,
// either.
//
// Original also had a comment:
// make sure the directory exists before we watch for
// changes. When we do a recursive watch and perform
// rm -rf, the parent directory might have gone
// missing, ignore the missing directory and let the
// upcoming delete event remove the watch from the
// parent directory.
err := w.dirChange(fileDir)
if !w.sendError(err) {
return
}
}
} else {
path := filepath.Clean(event.Name)
if fi, err := os.Lstat(path); err == nil {
err := w.sendCreateIfNew(path, fi)
if !w.sendError(err) {
return
}
}
}
}
}
}
}
// newEvent returns an platform-independent Event based on kqueue Fflags.
func (w *kqueue) newEvent(name, linkName string, mask uint32) Event {
e := Event{Name: name}
if linkName != "" {
// If the user watched "/path/link" then emit events as "/path/link"
// rather than "/path/target".
e.Name = linkName
}
if mask&unix.NOTE_DELETE == unix.NOTE_DELETE {
e.Op |= Remove
}
if mask&unix.NOTE_WRITE == unix.NOTE_WRITE {
e.Op |= Write
}
if mask&unix.NOTE_RENAME == unix.NOTE_RENAME {
e.Op |= Rename
}
if mask&unix.NOTE_ATTRIB == unix.NOTE_ATTRIB {
e.Op |= Chmod
}
// No point sending a write and delete event at the same time: if it's gone,
// then it's gone.
if e.Op.Has(Write) && e.Op.Has(Remove) {
e.Op &^= Write
}
return e
}
// watchDirectoryFiles to mimic inotify when adding a watch on a directory
func (w *kqueue) watchDirectoryFiles(dirPath string) error {
files, err := os.ReadDir(dirPath)
if err != nil {
return err
}
for _, f := range files {
path := filepath.Join(dirPath, f.Name())
fi, err := f.Info()
if err != nil {
return fmt.Errorf("%q: %w", path, err)
}
cleanPath, err := w.internalWatch(path, fi)
if err != nil {
// No permission to read the file; that's not a problem: just skip.
// But do add it to w.fileExists to prevent it from being picked up
// as a "new" file later (it still shows up in the directory
// listing).
switch {
case errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM):
cleanPath = filepath.Clean(path)
default:
return fmt.Errorf("%q: %w", path, err)
}
}
w.watches.markSeen(cleanPath, true)
}
return nil
}
// Search the directory for new files and send an event for them.
//
// This functionality is to have the BSD watcher match the inotify, which sends
// a create event for files created in a watched directory.
func (w *kqueue) dirChange(dir string) error {
files, err := os.ReadDir(dir)
if err != nil {
// Directory no longer exists: we can ignore this safely. kqueue will
// still give us the correct events.
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("fsnotify.dirChange %q: %w", dir, err)
}
for _, f := range files {
fi, err := f.Info()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("fsnotify.dirChange: %w", err)
}
err = w.sendCreateIfNew(filepath.Join(dir, fi.Name()), fi)
if err != nil {
// Don't need to send an error if this file isn't readable.
if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) || errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("fsnotify.dirChange: %w", err)
}
}
return nil
}
// Send a create event if the file isn't already being tracked, and start
// watching this file.
func (w *kqueue) sendCreateIfNew(path string, fi os.FileInfo) error {
if !w.watches.seenBefore(path) {
if !w.sendEvent(Event{Name: path, Op: Create}) {
return nil
}
}
// Like watchDirectoryFiles, but without doing another ReadDir.
path, err := w.internalWatch(path, fi)
if err != nil {
return err
}
w.watches.markSeen(path, true)
return nil
}
func (w *kqueue) internalWatch(name string, fi os.FileInfo) (string, error) {
if fi.IsDir() {
// mimic Linux providing delete events for subdirectories, but preserve
// the flags used if currently watching subdirectory
info, _ := w.watches.byPath(name)
return w.addWatch(name, info.dirFlags|unix.NOTE_DELETE|unix.NOTE_RENAME, true)
}
// Watch file to mimic Linux inotify.
return w.addWatch(name, noteAllEvents, true)
}
// Register events with the queue.
func (w *kqueue) register(fds []int, flags int, fflags uint32) error {
changes := make([]unix.Kevent_t, len(fds))
for i, fd := range fds {
// SetKevent converts int to the platform-specific types.
unix.SetKevent(&changes[i], fd, unix.EVFILT_VNODE, flags)
changes[i].Fflags = fflags
}
// Register the events.
success, err := unix.Kevent(w.kq, changes, nil, nil)
if success == -1 {
return err
}
return nil
}
// read retrieves pending events, or waits until an event occurs.
func (w *kqueue) read(events []unix.Kevent_t) ([]unix.Kevent_t, error) {
n, err := unix.Kevent(w.kq, nil, events, nil)
if err != nil {
return nil, err
}
return events[0:n], nil
}
func (w *kqueue) xSupports(op Op) bool {
//if runtime.GOOS == "freebsd" {
// return true // Supports everything.
//}
if op.Has(xUnportableOpen) || op.Has(xUnportableRead) ||
op.Has(xUnportableCloseWrite) || op.Has(xUnportableCloseRead) {
return false
}
return true
}
+22
View File
@@ -0,0 +1,22 @@
//go:build appengine || (!darwin && !dragonfly && !freebsd && !openbsd && !linux && !netbsd && !solaris && !windows)
package fsnotify
import "errors"
type other struct {
Events chan Event
Errors chan error
}
var defaultBufferSize = 0
func newBackend(ev chan Event, errs chan error) (backend, error) {
return nil, errors.New("fsnotify not supported on the current platform")
}
func (w *other) Close() error { return nil }
func (w *other) WatchList() []string { return nil }
func (w *other) Add(name string) error { return nil }
func (w *other) AddWith(name string, opts ...addOpt) error { return nil }
func (w *other) Remove(name string) error { return nil }
func (w *other) xSupports(op Op) bool { return false }
+680
View File
@@ -0,0 +1,680 @@
//go:build windows
// Windows backend based on ReadDirectoryChangesW()
//
// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-readdirectorychangesw
package fsnotify
import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"time"
"unsafe"
"github.com/fsnotify/fsnotify/internal"
"golang.org/x/sys/windows"
)
type readDirChangesW struct {
Events chan Event
Errors chan error
port windows.Handle // Handle to completion port
input chan *input // Inputs to the reader are sent on this channel
done chan chan<- error
mu sync.Mutex // Protects access to watches, closed
watches watchMap // Map of watches (key: i-number)
closed bool // Set to true when Close() is first called
}
var defaultBufferSize = 50
func newBackend(ev chan Event, errs chan error) (backend, error) {
port, err := windows.CreateIoCompletionPort(windows.InvalidHandle, 0, 0, 0)
if err != nil {
return nil, os.NewSyscallError("CreateIoCompletionPort", err)
}
w := &readDirChangesW{
Events: ev,
Errors: errs,
port: port,
watches: make(watchMap),
input: make(chan *input, 1),
done: make(chan chan<- error, 1),
}
go w.readEvents()
return w, nil
}
func (w *readDirChangesW) isClosed() bool {
w.mu.Lock()
defer w.mu.Unlock()
return w.closed
}
func (w *readDirChangesW) sendEvent(name, renamedFrom string, mask uint64) bool {
if mask == 0 {
return false
}
event := w.newEvent(name, uint32(mask))
event.renamedFrom = renamedFrom
select {
case ch := <-w.done:
w.done <- ch
case w.Events <- event:
}
return true
}
// Returns true if the error was sent, or false if watcher is closed.
func (w *readDirChangesW) sendError(err error) bool {
if err == nil {
return true
}
select {
case <-w.done:
return false
case w.Errors <- err:
return true
}
}
func (w *readDirChangesW) Close() error {
if w.isClosed() {
return nil
}
w.mu.Lock()
w.closed = true
w.mu.Unlock()
// Send "done" message to the reader goroutine
ch := make(chan error)
w.done <- ch
if err := w.wakeupReader(); err != nil {
return err
}
return <-ch
}
func (w *readDirChangesW) Add(name string) error { return w.AddWith(name) }
func (w *readDirChangesW) AddWith(name string, opts ...addOpt) error {
if w.isClosed() {
return ErrClosed
}
if debug {
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s AddWith(%q)\n",
time.Now().Format("15:04:05.000000000"), filepath.ToSlash(name))
}
with := getOptions(opts...)
if !w.xSupports(with.op) {
return fmt.Errorf("%w: %s", xErrUnsupported, with.op)
}
if with.bufsize < 4096 {
return fmt.Errorf("fsnotify.WithBufferSize: buffer size cannot be smaller than 4096 bytes")
}
in := &input{
op: opAddWatch,
path: filepath.Clean(name),
flags: sysFSALLEVENTS,
reply: make(chan error),
bufsize: with.bufsize,
}
w.input <- in
if err := w.wakeupReader(); err != nil {
return err
}
return <-in.reply
}
func (w *readDirChangesW) Remove(name string) error {
if w.isClosed() {
return nil
}
if debug {
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s Remove(%q)\n",
time.Now().Format("15:04:05.000000000"), filepath.ToSlash(name))
}
in := &input{
op: opRemoveWatch,
path: filepath.Clean(name),
reply: make(chan error),
}
w.input <- in
if err := w.wakeupReader(); err != nil {
return err
}
return <-in.reply
}
func (w *readDirChangesW) WatchList() []string {
if w.isClosed() {
return nil
}
w.mu.Lock()
defer w.mu.Unlock()
entries := make([]string, 0, len(w.watches))
for _, entry := range w.watches {
for _, watchEntry := range entry {
for name := range watchEntry.names {
entries = append(entries, filepath.Join(watchEntry.path, name))
}
// the directory itself is being watched
if watchEntry.mask != 0 {
entries = append(entries, watchEntry.path)
}
}
}
return entries
}
// These options are from the old golang.org/x/exp/winfsnotify, where you could
// add various options to the watch. This has long since been removed.
//
// The "sys" in the name is misleading as they're not part of any "system".
//
// This should all be removed at some point, and just use windows.FILE_NOTIFY_*
const (
sysFSALLEVENTS = 0xfff
sysFSCREATE = 0x100
sysFSDELETE = 0x200
sysFSDELETESELF = 0x400
sysFSMODIFY = 0x2
sysFSMOVE = 0xc0
sysFSMOVEDFROM = 0x40
sysFSMOVEDTO = 0x80
sysFSMOVESELF = 0x800
sysFSIGNORED = 0x8000
)
func (w *readDirChangesW) newEvent(name string, mask uint32) Event {
e := Event{Name: name}
if mask&sysFSCREATE == sysFSCREATE || mask&sysFSMOVEDTO == sysFSMOVEDTO {
e.Op |= Create
}
if mask&sysFSDELETE == sysFSDELETE || mask&sysFSDELETESELF == sysFSDELETESELF {
e.Op |= Remove
}
if mask&sysFSMODIFY == sysFSMODIFY {
e.Op |= Write
}
if mask&sysFSMOVE == sysFSMOVE || mask&sysFSMOVESELF == sysFSMOVESELF || mask&sysFSMOVEDFROM == sysFSMOVEDFROM {
e.Op |= Rename
}
return e
}
const (
opAddWatch = iota
opRemoveWatch
)
const (
provisional uint64 = 1 << (32 + iota)
)
type input struct {
op int
path string
flags uint32
bufsize int
reply chan error
}
type inode struct {
handle windows.Handle
volume uint32
index uint64
}
type watch struct {
ov windows.Overlapped
ino *inode // i-number
recurse bool // Recursive watch?
path string // Directory path
mask uint64 // Directory itself is being watched with these notify flags
names map[string]uint64 // Map of names being watched and their notify flags
rename string // Remembers the old name while renaming a file
buf []byte // buffer, allocated later
}
type (
indexMap map[uint64]*watch
watchMap map[uint32]indexMap
)
func (w *readDirChangesW) wakeupReader() error {
err := windows.PostQueuedCompletionStatus(w.port, 0, 0, nil)
if err != nil {
return os.NewSyscallError("PostQueuedCompletionStatus", err)
}
return nil
}
func (w *readDirChangesW) getDir(pathname string) (dir string, err error) {
attr, err := windows.GetFileAttributes(windows.StringToUTF16Ptr(pathname))
if err != nil {
return "", os.NewSyscallError("GetFileAttributes", err)
}
if attr&windows.FILE_ATTRIBUTE_DIRECTORY != 0 {
dir = pathname
} else {
dir, _ = filepath.Split(pathname)
dir = filepath.Clean(dir)
}
return
}
func (w *readDirChangesW) getIno(path string) (ino *inode, err error) {
h, err := windows.CreateFile(windows.StringToUTF16Ptr(path),
windows.FILE_LIST_DIRECTORY,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil, windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OVERLAPPED, 0)
if err != nil {
return nil, os.NewSyscallError("CreateFile", err)
}
var fi windows.ByHandleFileInformation
err = windows.GetFileInformationByHandle(h, &fi)
if err != nil {
windows.CloseHandle(h)
return nil, os.NewSyscallError("GetFileInformationByHandle", err)
}
ino = &inode{
handle: h,
volume: fi.VolumeSerialNumber,
index: uint64(fi.FileIndexHigh)<<32 | uint64(fi.FileIndexLow),
}
return ino, nil
}
// Must run within the I/O thread.
func (m watchMap) get(ino *inode) *watch {
if i := m[ino.volume]; i != nil {
return i[ino.index]
}
return nil
}
// Must run within the I/O thread.
func (m watchMap) set(ino *inode, watch *watch) {
i := m[ino.volume]
if i == nil {
i = make(indexMap)
m[ino.volume] = i
}
i[ino.index] = watch
}
// Must run within the I/O thread.
func (w *readDirChangesW) addWatch(pathname string, flags uint64, bufsize int) error {
pathname, recurse := recursivePath(pathname)
dir, err := w.getDir(pathname)
if err != nil {
return err
}
ino, err := w.getIno(dir)
if err != nil {
return err
}
w.mu.Lock()
watchEntry := w.watches.get(ino)
w.mu.Unlock()
if watchEntry == nil {
_, err := windows.CreateIoCompletionPort(ino.handle, w.port, 0, 0)
if err != nil {
windows.CloseHandle(ino.handle)
return os.NewSyscallError("CreateIoCompletionPort", err)
}
watchEntry = &watch{
ino: ino,
path: dir,
names: make(map[string]uint64),
recurse: recurse,
buf: make([]byte, bufsize),
}
w.mu.Lock()
w.watches.set(ino, watchEntry)
w.mu.Unlock()
flags |= provisional
} else {
windows.CloseHandle(ino.handle)
}
if pathname == dir {
watchEntry.mask |= flags
} else {
watchEntry.names[filepath.Base(pathname)] |= flags
}
err = w.startRead(watchEntry)
if err != nil {
return err
}
if pathname == dir {
watchEntry.mask &= ^provisional
} else {
watchEntry.names[filepath.Base(pathname)] &= ^provisional
}
return nil
}
// Must run within the I/O thread.
func (w *readDirChangesW) remWatch(pathname string) error {
pathname, recurse := recursivePath(pathname)
dir, err := w.getDir(pathname)
if err != nil {
return err
}
ino, err := w.getIno(dir)
if err != nil {
return err
}
w.mu.Lock()
watch := w.watches.get(ino)
w.mu.Unlock()
if recurse && !watch.recurse {
return fmt.Errorf("can't use \\... with non-recursive watch %q", pathname)
}
err = windows.CloseHandle(ino.handle)
if err != nil {
w.sendError(os.NewSyscallError("CloseHandle", err))
}
if watch == nil {
return fmt.Errorf("%w: %s", ErrNonExistentWatch, pathname)
}
if pathname == dir {
w.sendEvent(watch.path, "", watch.mask&sysFSIGNORED)
watch.mask = 0
} else {
name := filepath.Base(pathname)
w.sendEvent(filepath.Join(watch.path, name), "", watch.names[name]&sysFSIGNORED)
delete(watch.names, name)
}
return w.startRead(watch)
}
// Must run within the I/O thread.
func (w *readDirChangesW) deleteWatch(watch *watch) {
for name, mask := range watch.names {
if mask&provisional == 0 {
w.sendEvent(filepath.Join(watch.path, name), "", mask&sysFSIGNORED)
}
delete(watch.names, name)
}
if watch.mask != 0 {
if watch.mask&provisional == 0 {
w.sendEvent(watch.path, "", watch.mask&sysFSIGNORED)
}
watch.mask = 0
}
}
// Must run within the I/O thread.
func (w *readDirChangesW) startRead(watch *watch) error {
err := windows.CancelIo(watch.ino.handle)
if err != nil {
w.sendError(os.NewSyscallError("CancelIo", err))
w.deleteWatch(watch)
}
mask := w.toWindowsFlags(watch.mask)
for _, m := range watch.names {
mask |= w.toWindowsFlags(m)
}
if mask == 0 {
err := windows.CloseHandle(watch.ino.handle)
if err != nil {
w.sendError(os.NewSyscallError("CloseHandle", err))
}
w.mu.Lock()
delete(w.watches[watch.ino.volume], watch.ino.index)
w.mu.Unlock()
return nil
}
// We need to pass the array, rather than the slice.
hdr := (*reflect.SliceHeader)(unsafe.Pointer(&watch.buf))
rdErr := windows.ReadDirectoryChanges(watch.ino.handle,
(*byte)(unsafe.Pointer(hdr.Data)), uint32(hdr.Len),
watch.recurse, mask, nil, &watch.ov, 0)
if rdErr != nil {
err := os.NewSyscallError("ReadDirectoryChanges", rdErr)
if rdErr == windows.ERROR_ACCESS_DENIED && watch.mask&provisional == 0 {
// Watched directory was probably removed
w.sendEvent(watch.path, "", watch.mask&sysFSDELETESELF)
err = nil
}
w.deleteWatch(watch)
w.startRead(watch)
return err
}
return nil
}
// readEvents reads from the I/O completion port, converts the
// received events into Event objects and sends them via the Events channel.
// Entry point to the I/O thread.
func (w *readDirChangesW) readEvents() {
var (
n uint32
key uintptr
ov *windows.Overlapped
)
runtime.LockOSThread()
for {
// This error is handled after the watch == nil check below.
qErr := windows.GetQueuedCompletionStatus(w.port, &n, &key, &ov, windows.INFINITE)
watch := (*watch)(unsafe.Pointer(ov))
if watch == nil {
select {
case ch := <-w.done:
w.mu.Lock()
var indexes []indexMap
for _, index := range w.watches {
indexes = append(indexes, index)
}
w.mu.Unlock()
for _, index := range indexes {
for _, watch := range index {
w.deleteWatch(watch)
w.startRead(watch)
}
}
err := windows.CloseHandle(w.port)
if err != nil {
err = os.NewSyscallError("CloseHandle", err)
}
close(w.Events)
close(w.Errors)
ch <- err
return
case in := <-w.input:
switch in.op {
case opAddWatch:
in.reply <- w.addWatch(in.path, uint64(in.flags), in.bufsize)
case opRemoveWatch:
in.reply <- w.remWatch(in.path)
}
default:
}
continue
}
switch qErr {
case nil:
// No error
case windows.ERROR_MORE_DATA:
if watch == nil {
w.sendError(errors.New("ERROR_MORE_DATA has unexpectedly null lpOverlapped buffer"))
} else {
// The i/o succeeded but the buffer is full.
// In theory we should be building up a full packet.
// In practice we can get away with just carrying on.
n = uint32(unsafe.Sizeof(watch.buf))
}
case windows.ERROR_ACCESS_DENIED:
// Watched directory was probably removed
w.sendEvent(watch.path, "", watch.mask&sysFSDELETESELF)
w.deleteWatch(watch)
w.startRead(watch)
continue
case windows.ERROR_OPERATION_ABORTED:
// CancelIo was called on this handle
continue
default:
w.sendError(os.NewSyscallError("GetQueuedCompletionPort", qErr))
continue
}
var offset uint32
for {
if n == 0 {
w.sendError(ErrEventOverflow)
break
}
// Point "raw" to the event in the buffer
raw := (*windows.FileNotifyInformation)(unsafe.Pointer(&watch.buf[offset]))
// Create a buf that is the size of the path name
size := int(raw.FileNameLength / 2)
var buf []uint16
// TODO: Use unsafe.Slice in Go 1.17; https://stackoverflow.com/questions/51187973
sh := (*reflect.SliceHeader)(unsafe.Pointer(&buf))
sh.Data = uintptr(unsafe.Pointer(&raw.FileName))
sh.Len = size
sh.Cap = size
name := windows.UTF16ToString(buf)
fullname := filepath.Join(watch.path, name)
if debug {
internal.Debug(fullname, raw.Action)
}
var mask uint64
switch raw.Action {
case windows.FILE_ACTION_REMOVED:
mask = sysFSDELETESELF
case windows.FILE_ACTION_MODIFIED:
mask = sysFSMODIFY
case windows.FILE_ACTION_RENAMED_OLD_NAME:
watch.rename = name
case windows.FILE_ACTION_RENAMED_NEW_NAME:
// Update saved path of all sub-watches.
old := filepath.Join(watch.path, watch.rename)
w.mu.Lock()
for _, watchMap := range w.watches {
for _, ww := range watchMap {
if strings.HasPrefix(ww.path, old) {
ww.path = filepath.Join(fullname, strings.TrimPrefix(ww.path, old))
}
}
}
w.mu.Unlock()
if watch.names[watch.rename] != 0 {
watch.names[name] |= watch.names[watch.rename]
delete(watch.names, watch.rename)
mask = sysFSMOVESELF
}
}
if raw.Action != windows.FILE_ACTION_RENAMED_NEW_NAME {
w.sendEvent(fullname, "", watch.names[name]&mask)
}
if raw.Action == windows.FILE_ACTION_REMOVED {
w.sendEvent(fullname, "", watch.names[name]&sysFSIGNORED)
delete(watch.names, name)
}
if watch.rename != "" && raw.Action == windows.FILE_ACTION_RENAMED_NEW_NAME {
w.sendEvent(fullname, filepath.Join(watch.path, watch.rename), watch.mask&w.toFSnotifyFlags(raw.Action))
} else {
w.sendEvent(fullname, "", watch.mask&w.toFSnotifyFlags(raw.Action))
}
if raw.Action == windows.FILE_ACTION_RENAMED_NEW_NAME {
w.sendEvent(filepath.Join(watch.path, watch.rename), "", watch.names[name]&mask)
}
// Move to the next event in the buffer
if raw.NextEntryOffset == 0 {
break
}
offset += raw.NextEntryOffset
// Error!
if offset >= n {
//lint:ignore ST1005 Windows should be capitalized
w.sendError(errors.New("Windows system assumed buffer larger than it is, events have likely been missed"))
break
}
}
if err := w.startRead(watch); err != nil {
w.sendError(err)
}
}
}
func (w *readDirChangesW) toWindowsFlags(mask uint64) uint32 {
var m uint32
if mask&sysFSMODIFY != 0 {
m |= windows.FILE_NOTIFY_CHANGE_LAST_WRITE
}
if mask&(sysFSMOVE|sysFSCREATE|sysFSDELETE) != 0 {
m |= windows.FILE_NOTIFY_CHANGE_FILE_NAME | windows.FILE_NOTIFY_CHANGE_DIR_NAME
}
return m
}
func (w *readDirChangesW) toFSnotifyFlags(action uint32) uint64 {
switch action {
case windows.FILE_ACTION_ADDED:
return sysFSCREATE
case windows.FILE_ACTION_REMOVED:
return sysFSDELETE
case windows.FILE_ACTION_MODIFIED:
return sysFSMODIFY
case windows.FILE_ACTION_RENAMED_OLD_NAME:
return sysFSMOVEDFROM
case windows.FILE_ACTION_RENAMED_NEW_NAME:
return sysFSMOVEDTO
}
return 0
}
func (w *readDirChangesW) xSupports(op Op) bool {
if op.Has(xUnportableOpen) || op.Has(xUnportableRead) ||
op.Has(xUnportableCloseWrite) || op.Has(xUnportableCloseRead) {
return false
}
return true
}
+496
View File
@@ -0,0 +1,496 @@
// Package fsnotify provides a cross-platform interface for file system
// notifications.
//
// Currently supported systems:
//
// - Linux via inotify
// - BSD, macOS via kqueue
// - Windows via ReadDirectoryChangesW
// - illumos via FEN
//
// # FSNOTIFY_DEBUG
//
// Set the FSNOTIFY_DEBUG environment variable to "1" to print debug messages to
// stderr. This can be useful to track down some problems, especially in cases
// where fsnotify is used as an indirect dependency.
//
// Every event will be printed as soon as there's something useful to print,
// with as little processing from fsnotify.
//
// Example output:
//
// FSNOTIFY_DEBUG: 11:34:23.633087586 256:IN_CREATE → "/tmp/file-1"
// FSNOTIFY_DEBUG: 11:34:23.633202319 4:IN_ATTRIB → "/tmp/file-1"
// FSNOTIFY_DEBUG: 11:34:28.989728764 512:IN_DELETE → "/tmp/file-1"
package fsnotify
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// Watcher watches a set of paths, delivering events on a channel.
//
// A watcher should not be copied (e.g. pass it by pointer, rather than by
// value).
//
// # Linux notes
//
// When a file is removed a Remove event won't be emitted until all file
// descriptors are closed, and deletes will always emit a Chmod. For example:
//
// fp := os.Open("file")
// os.Remove("file") // Triggers Chmod
// fp.Close() // Triggers Remove
//
// This is the event that inotify sends, so not much can be changed about this.
//
// The fs.inotify.max_user_watches sysctl variable specifies the upper limit
// for the number of watches per user, and fs.inotify.max_user_instances
// specifies the maximum number of inotify instances per user. Every Watcher you
// create is an "instance", and every path you add is a "watch".
//
// These are also exposed in /proc as /proc/sys/fs/inotify/max_user_watches and
// /proc/sys/fs/inotify/max_user_instances
//
// To increase them you can use sysctl or write the value to the /proc file:
//
// # Default values on Linux 5.18
// sysctl fs.inotify.max_user_watches=124983
// sysctl fs.inotify.max_user_instances=128
//
// To make the changes persist on reboot edit /etc/sysctl.conf or
// /usr/lib/sysctl.d/50-default.conf (details differ per Linux distro; check
// your distro's documentation):
//
// fs.inotify.max_user_watches=124983
// fs.inotify.max_user_instances=128
//
// Reaching the limit will result in a "no space left on device" or "too many open
// files" error.
//
// # kqueue notes (macOS, BSD)
//
// kqueue requires opening a file descriptor for every file that's being watched;
// so if you're watching a directory with five files then that's six file
// descriptors. You will run in to your system's "max open files" limit faster on
// these platforms.
//
// The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to
// control the maximum number of open files, as well as /etc/login.conf on BSD
// systems.
//
// # Windows notes
//
// Paths can be added as "C:\\path\\to\\dir", but forward slashes
// ("C:/path/to/dir") will also work.
//
// When a watched directory is removed it will always send an event for the
// directory itself, but may not send events for all files in that directory.
// Sometimes it will send events for all files, sometimes it will send no
// events, and often only for some files.
//
// The default ReadDirectoryChangesW() buffer size is 64K, which is the largest
// value that is guaranteed to work with SMB filesystems. If you have many
// events in quick succession this may not be enough, and you will have to use
// [WithBufferSize] to increase the value.
type Watcher struct {
b backend
// Events sends the filesystem change events.
//
// fsnotify can send the following events; a "path" here can refer to a
// file, directory, symbolic link, or special file like a FIFO.
//
// fsnotify.Create A new path was created; this may be followed by one
// or more Write events if data also gets written to a
// file.
//
// fsnotify.Remove A path was removed.
//
// fsnotify.Rename A path was renamed. A rename is always sent with the
// old path as Event.Name, and a Create event will be
// sent with the new name. Renames are only sent for
// paths that are currently watched; e.g. moving an
// unmonitored file into a monitored directory will
// show up as just a Create. Similarly, renaming a file
// to outside a monitored directory will show up as
// only a Rename.
//
// fsnotify.Write A file or named pipe was written to. A Truncate will
// also trigger a Write. A single "write action"
// initiated by the user may show up as one or multiple
// writes, depending on when the system syncs things to
// disk. For example when compiling a large Go program
// you may get hundreds of Write events, and you may
// want to wait until you've stopped receiving them
// (see the dedup example in cmd/fsnotify).
//
// Some systems may send Write event for directories
// when the directory content changes.
//
// fsnotify.Chmod Attributes were changed. On Linux this is also sent
// when a file is removed (or more accurately, when a
// link to an inode is removed). On kqueue it's sent
// when a file is truncated. On Windows it's never
// sent.
Events chan Event
// Errors sends any errors.
Errors chan error
}
// Event represents a file system notification.
type Event struct {
// Path to the file or directory.
//
// Paths are relative to the input; for example with Add("dir") the Name
// will be set to "dir/file" if you create that file, but if you use
// Add("/path/to/dir") it will be "/path/to/dir/file".
Name string
// File operation that triggered the event.
//
// This is a bitmask and some systems may send multiple operations at once.
// Use the Event.Has() method instead of comparing with ==.
Op Op
// Create events will have this set to the old path if it's a rename. This
// only works when both the source and destination are watched. It's not
// reliable when watching individual files, only directories.
//
// For example "mv /tmp/file /tmp/rename" will emit:
//
// Event{Op: Rename, Name: "/tmp/file"}
// Event{Op: Create, Name: "/tmp/rename", RenamedFrom: "/tmp/file"}
renamedFrom string
}
// Op describes a set of file operations.
type Op uint32
// The operations fsnotify can trigger; see the documentation on [Watcher] for a
// full description, and check them with [Event.Has].
const (
// A new pathname was created.
Create Op = 1 << iota
// The pathname was written to; this does *not* mean the write has finished,
// and a write can be followed by more writes.
Write
// The path was removed; any watches on it will be removed. Some "remove"
// operations may trigger a Rename if the file is actually moved (for
// example "remove to trash" is often a rename).
Remove
// The path was renamed to something else; any watches on it will be
// removed.
Rename
// File attributes were changed.
//
// It's generally not recommended to take action on this event, as it may
// get triggered very frequently by some software. For example, Spotlight
// indexing on macOS, anti-virus software, backup software, etc.
Chmod
// File descriptor was opened.
//
// Only works on Linux and FreeBSD.
xUnportableOpen
// File was read from.
//
// Only works on Linux and FreeBSD.
xUnportableRead
// File opened for writing was closed.
//
// Only works on Linux and FreeBSD.
//
// The advantage of using this over Write is that it's more reliable than
// waiting for Write events to stop. It's also faster (if you're not
// listening to Write events): copying a file of a few GB can easily
// generate tens of thousands of Write events in a short span of time.
xUnportableCloseWrite
// File opened for reading was closed.
//
// Only works on Linux and FreeBSD.
xUnportableCloseRead
)
var (
// ErrNonExistentWatch is used when Remove() is called on a path that's not
// added.
ErrNonExistentWatch = errors.New("fsnotify: can't remove non-existent watch")
// ErrClosed is used when trying to operate on a closed Watcher.
ErrClosed = errors.New("fsnotify: watcher already closed")
// ErrEventOverflow is reported from the Errors channel when there are too
// many events:
//
// - inotify: inotify returns IN_Q_OVERFLOW because there are too
// many queued events (the fs.inotify.max_queued_events
// sysctl can be used to increase this).
// - windows: The buffer size is too small; WithBufferSize() can be used to increase it.
// - kqueue, fen: Not used.
ErrEventOverflow = errors.New("fsnotify: queue or buffer overflow")
// ErrUnsupported is returned by AddWith() when WithOps() specified an
// Unportable event that's not supported on this platform.
//lint:ignore ST1012 not relevant
xErrUnsupported = errors.New("fsnotify: not supported with this backend")
)
// NewWatcher creates a new Watcher.
func NewWatcher() (*Watcher, error) {
ev, errs := make(chan Event, defaultBufferSize), make(chan error)
b, err := newBackend(ev, errs)
if err != nil {
return nil, err
}
return &Watcher{b: b, Events: ev, Errors: errs}, nil
}
// NewBufferedWatcher creates a new Watcher with a buffered Watcher.Events
// channel.
//
// The main use case for this is situations with a very large number of events
// where the kernel buffer size can't be increased (e.g. due to lack of
// permissions). An unbuffered Watcher will perform better for almost all use
// cases, and whenever possible you will be better off increasing the kernel
// buffers instead of adding a large userspace buffer.
func NewBufferedWatcher(sz uint) (*Watcher, error) {
ev, errs := make(chan Event, sz), make(chan error)
b, err := newBackend(ev, errs)
if err != nil {
return nil, err
}
return &Watcher{b: b, Events: ev, Errors: errs}, nil
}
// Add starts monitoring the path for changes.
//
// A path can only be watched once; watching it more than once is a no-op and will
// not return an error. Paths that do not yet exist on the filesystem cannot be
// watched.
//
// A watch will be automatically removed if the watched path is deleted or
// renamed. The exception is the Windows backend, which doesn't remove the
// watcher on renames.
//
// Notifications on network filesystems (NFS, SMB, FUSE, etc.) or special
// filesystems (/proc, /sys, etc.) generally don't work.
//
// Returns [ErrClosed] if [Watcher.Close] was called.
//
// See [Watcher.AddWith] for a version that allows adding options.
//
// # Watching directories
//
// All files in a directory are monitored, including new files that are created
// after the watcher is started. Subdirectories are not watched (i.e. it's
// non-recursive).
//
// # Watching files
//
// Watching individual files (rather than directories) is generally not
// recommended as many programs (especially editors) update files atomically: it
// will write to a temporary file which is then moved to destination,
// overwriting the original (or some variant thereof). The watcher on the
// original file is now lost, as that no longer exists.
//
// The upshot of this is that a power failure or crash won't leave a
// half-written file.
//
// Watch the parent directory and use Event.Name to filter out files you're not
// interested in. There is an example of this in cmd/fsnotify/file.go.
func (w *Watcher) Add(path string) error { return w.b.Add(path) }
// AddWith is like [Watcher.Add], but allows adding options. When using Add()
// the defaults described below are used.
//
// Possible options are:
//
// - [WithBufferSize] sets the buffer size for the Windows backend; no-op on
// other platforms. The default is 64K (65536 bytes).
func (w *Watcher) AddWith(path string, opts ...addOpt) error { return w.b.AddWith(path, opts...) }
// Remove stops monitoring the path for changes.
//
// Directories are always removed non-recursively. For example, if you added
// /tmp/dir and /tmp/dir/subdir then you will need to remove both.
//
// Removing a path that has not yet been added returns [ErrNonExistentWatch].
//
// Returns nil if [Watcher.Close] was called.
func (w *Watcher) Remove(path string) error { return w.b.Remove(path) }
// Close removes all watches and closes the Events channel.
func (w *Watcher) Close() error { return w.b.Close() }
// WatchList returns all paths explicitly added with [Watcher.Add] (and are not
// yet removed).
//
// The order is undefined, and may differ per call. Returns nil if
// [Watcher.Close] was called.
func (w *Watcher) WatchList() []string { return w.b.WatchList() }
// Supports reports if all the listed operations are supported by this platform.
//
// Create, Write, Remove, Rename, and Chmod are always supported. It can only
// return false for an Op starting with Unportable.
func (w *Watcher) xSupports(op Op) bool { return w.b.xSupports(op) }
func (o Op) String() string {
var b strings.Builder
if o.Has(Create) {
b.WriteString("|CREATE")
}
if o.Has(Remove) {
b.WriteString("|REMOVE")
}
if o.Has(Write) {
b.WriteString("|WRITE")
}
if o.Has(xUnportableOpen) {
b.WriteString("|OPEN")
}
if o.Has(xUnportableRead) {
b.WriteString("|READ")
}
if o.Has(xUnportableCloseWrite) {
b.WriteString("|CLOSE_WRITE")
}
if o.Has(xUnportableCloseRead) {
b.WriteString("|CLOSE_READ")
}
if o.Has(Rename) {
b.WriteString("|RENAME")
}
if o.Has(Chmod) {
b.WriteString("|CHMOD")
}
if b.Len() == 0 {
return "[no events]"
}
return b.String()[1:]
}
// Has reports if this operation has the given operation.
func (o Op) Has(h Op) bool { return o&h != 0 }
// Has reports if this event has the given operation.
func (e Event) Has(op Op) bool { return e.Op.Has(op) }
// String returns a string representation of the event with their path.
func (e Event) String() string {
if e.renamedFrom != "" {
return fmt.Sprintf("%-13s %q ← %q", e.Op.String(), e.Name, e.renamedFrom)
}
return fmt.Sprintf("%-13s %q", e.Op.String(), e.Name)
}
type (
backend interface {
Add(string) error
AddWith(string, ...addOpt) error
Remove(string) error
WatchList() []string
Close() error
xSupports(Op) bool
}
addOpt func(opt *withOpts)
withOpts struct {
bufsize int
op Op
noFollow bool
sendCreate bool
}
)
var debug = func() bool {
// Check for exactly "1" (rather than mere existence) so we can add
// options/flags in the future. I don't know if we ever want that, but it's
// nice to leave the option open.
return os.Getenv("FSNOTIFY_DEBUG") == "1"
}()
var defaultOpts = withOpts{
bufsize: 65536, // 64K
op: Create | Write | Remove | Rename | Chmod,
}
func getOptions(opts ...addOpt) withOpts {
with := defaultOpts
for _, o := range opts {
if o != nil {
o(&with)
}
}
return with
}
// WithBufferSize sets the [ReadDirectoryChangesW] buffer size.
//
// This only has effect on Windows systems, and is a no-op for other backends.
//
// The default value is 64K (65536 bytes) which is the highest value that works
// on all filesystems and should be enough for most applications, but if you
// have a large burst of events it may not be enough. You can increase it if
// you're hitting "queue or buffer overflow" errors ([ErrEventOverflow]).
//
// [ReadDirectoryChangesW]: https://learn.microsoft.com/en-gb/windows/win32/api/winbase/nf-winbase-readdirectorychangesw
func WithBufferSize(bytes int) addOpt {
return func(opt *withOpts) { opt.bufsize = bytes }
}
// WithOps sets which operations to listen for. The default is [Create],
// [Write], [Remove], [Rename], and [Chmod].
//
// Excluding operations you're not interested in can save quite a bit of CPU
// time; in some use cases there may be hundreds of thousands of useless Write
// or Chmod operations per second.
//
// This can also be used to add unportable operations not supported by all
// platforms; unportable operations all start with "Unportable":
// [UnportableOpen], [UnportableRead], [UnportableCloseWrite], and
// [UnportableCloseRead].
//
// AddWith returns an error when using an unportable operation that's not
// supported. Use [Watcher.Support] to check for support.
func withOps(op Op) addOpt {
return func(opt *withOpts) { opt.op = op }
}
// WithNoFollow disables following symlinks, so the symlinks themselves are
// watched.
func withNoFollow() addOpt {
return func(opt *withOpts) { opt.noFollow = true }
}
// "Internal" option for recursive watches on inotify.
func withCreate() addOpt {
return func(opt *withOpts) { opt.sendCreate = true }
}
var enableRecurse = false
// Check if this path is recursive (ends with "/..." or "\..."), and return the
// path with the /... stripped.
func recursivePath(path string) (string, bool) {
path = filepath.Clean(path)
if !enableRecurse { // Only enabled in tests for now.
return path, false
}
if filepath.Base(path) == "..." {
return filepath.Dir(path), true
}
return path, false
}
+39
View File
@@ -0,0 +1,39 @@
//go:build darwin
package internal
import (
"syscall"
"golang.org/x/sys/unix"
)
var (
ErrSyscallEACCES = syscall.EACCES
ErrUnixEACCES = unix.EACCES
)
var maxfiles uint64
func SetRlimit() {
// Go 1.19 will do this automatically: https://go-review.googlesource.com/c/go/+/393354/
var l syscall.Rlimit
err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &l)
if err == nil && l.Cur != l.Max {
l.Cur = l.Max
syscall.Setrlimit(syscall.RLIMIT_NOFILE, &l)
}
maxfiles = l.Cur
if n, err := syscall.SysctlUint32("kern.maxfiles"); err == nil && uint64(n) < maxfiles {
maxfiles = uint64(n)
}
if n, err := syscall.SysctlUint32("kern.maxfilesperproc"); err == nil && uint64(n) < maxfiles {
maxfiles = uint64(n)
}
}
func Maxfiles() uint64 { return maxfiles }
func Mkfifo(path string, mode uint32) error { return unix.Mkfifo(path, mode) }
func Mknod(path string, mode uint32, dev int) error { return unix.Mknod(path, mode, dev) }
@@ -0,0 +1,57 @@
package internal
import "golang.org/x/sys/unix"
var names = []struct {
n string
m uint32
}{
{"NOTE_ABSOLUTE", unix.NOTE_ABSOLUTE},
{"NOTE_ATTRIB", unix.NOTE_ATTRIB},
{"NOTE_BACKGROUND", unix.NOTE_BACKGROUND},
{"NOTE_CHILD", unix.NOTE_CHILD},
{"NOTE_CRITICAL", unix.NOTE_CRITICAL},
{"NOTE_DELETE", unix.NOTE_DELETE},
{"NOTE_EXEC", unix.NOTE_EXEC},
{"NOTE_EXIT", unix.NOTE_EXIT},
{"NOTE_EXITSTATUS", unix.NOTE_EXITSTATUS},
{"NOTE_EXIT_CSERROR", unix.NOTE_EXIT_CSERROR},
{"NOTE_EXIT_DECRYPTFAIL", unix.NOTE_EXIT_DECRYPTFAIL},
{"NOTE_EXIT_DETAIL", unix.NOTE_EXIT_DETAIL},
{"NOTE_EXIT_DETAIL_MASK", unix.NOTE_EXIT_DETAIL_MASK},
{"NOTE_EXIT_MEMORY", unix.NOTE_EXIT_MEMORY},
{"NOTE_EXIT_REPARENTED", unix.NOTE_EXIT_REPARENTED},
{"NOTE_EXTEND", unix.NOTE_EXTEND},
{"NOTE_FFAND", unix.NOTE_FFAND},
{"NOTE_FFCOPY", unix.NOTE_FFCOPY},
{"NOTE_FFCTRLMASK", unix.NOTE_FFCTRLMASK},
{"NOTE_FFLAGSMASK", unix.NOTE_FFLAGSMASK},
{"NOTE_FFNOP", unix.NOTE_FFNOP},
{"NOTE_FFOR", unix.NOTE_FFOR},
{"NOTE_FORK", unix.NOTE_FORK},
{"NOTE_FUNLOCK", unix.NOTE_FUNLOCK},
{"NOTE_LEEWAY", unix.NOTE_LEEWAY},
{"NOTE_LINK", unix.NOTE_LINK},
{"NOTE_LOWAT", unix.NOTE_LOWAT},
{"NOTE_MACHTIME", unix.NOTE_MACHTIME},
{"NOTE_MACH_CONTINUOUS_TIME", unix.NOTE_MACH_CONTINUOUS_TIME},
{"NOTE_NONE", unix.NOTE_NONE},
{"NOTE_NSECONDS", unix.NOTE_NSECONDS},
{"NOTE_OOB", unix.NOTE_OOB},
//{"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK}, -0x100000 (?!)
{"NOTE_PDATAMASK", unix.NOTE_PDATAMASK},
{"NOTE_REAP", unix.NOTE_REAP},
{"NOTE_RENAME", unix.NOTE_RENAME},
{"NOTE_REVOKE", unix.NOTE_REVOKE},
{"NOTE_SECONDS", unix.NOTE_SECONDS},
{"NOTE_SIGNAL", unix.NOTE_SIGNAL},
{"NOTE_TRACK", unix.NOTE_TRACK},
{"NOTE_TRACKERR", unix.NOTE_TRACKERR},
{"NOTE_TRIGGER", unix.NOTE_TRIGGER},
{"NOTE_USECONDS", unix.NOTE_USECONDS},
{"NOTE_VM_ERROR", unix.NOTE_VM_ERROR},
{"NOTE_VM_PRESSURE", unix.NOTE_VM_PRESSURE},
{"NOTE_VM_PRESSURE_SUDDEN_TERMINATE", unix.NOTE_VM_PRESSURE_SUDDEN_TERMINATE},
{"NOTE_VM_PRESSURE_TERMINATE", unix.NOTE_VM_PRESSURE_TERMINATE},
{"NOTE_WRITE", unix.NOTE_WRITE},
}
@@ -0,0 +1,33 @@
package internal
import "golang.org/x/sys/unix"
var names = []struct {
n string
m uint32
}{
{"NOTE_ATTRIB", unix.NOTE_ATTRIB},
{"NOTE_CHILD", unix.NOTE_CHILD},
{"NOTE_DELETE", unix.NOTE_DELETE},
{"NOTE_EXEC", unix.NOTE_EXEC},
{"NOTE_EXIT", unix.NOTE_EXIT},
{"NOTE_EXTEND", unix.NOTE_EXTEND},
{"NOTE_FFAND", unix.NOTE_FFAND},
{"NOTE_FFCOPY", unix.NOTE_FFCOPY},
{"NOTE_FFCTRLMASK", unix.NOTE_FFCTRLMASK},
{"NOTE_FFLAGSMASK", unix.NOTE_FFLAGSMASK},
{"NOTE_FFNOP", unix.NOTE_FFNOP},
{"NOTE_FFOR", unix.NOTE_FFOR},
{"NOTE_FORK", unix.NOTE_FORK},
{"NOTE_LINK", unix.NOTE_LINK},
{"NOTE_LOWAT", unix.NOTE_LOWAT},
{"NOTE_OOB", unix.NOTE_OOB},
{"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK},
{"NOTE_PDATAMASK", unix.NOTE_PDATAMASK},
{"NOTE_RENAME", unix.NOTE_RENAME},
{"NOTE_REVOKE", unix.NOTE_REVOKE},
{"NOTE_TRACK", unix.NOTE_TRACK},
{"NOTE_TRACKERR", unix.NOTE_TRACKERR},
{"NOTE_TRIGGER", unix.NOTE_TRIGGER},
{"NOTE_WRITE", unix.NOTE_WRITE},
}
@@ -0,0 +1,42 @@
package internal
import "golang.org/x/sys/unix"
var names = []struct {
n string
m uint32
}{
{"NOTE_ABSTIME", unix.NOTE_ABSTIME},
{"NOTE_ATTRIB", unix.NOTE_ATTRIB},
{"NOTE_CHILD", unix.NOTE_CHILD},
{"NOTE_CLOSE", unix.NOTE_CLOSE},
{"NOTE_CLOSE_WRITE", unix.NOTE_CLOSE_WRITE},
{"NOTE_DELETE", unix.NOTE_DELETE},
{"NOTE_EXEC", unix.NOTE_EXEC},
{"NOTE_EXIT", unix.NOTE_EXIT},
{"NOTE_EXTEND", unix.NOTE_EXTEND},
{"NOTE_FFAND", unix.NOTE_FFAND},
{"NOTE_FFCOPY", unix.NOTE_FFCOPY},
{"NOTE_FFCTRLMASK", unix.NOTE_FFCTRLMASK},
{"NOTE_FFLAGSMASK", unix.NOTE_FFLAGSMASK},
{"NOTE_FFNOP", unix.NOTE_FFNOP},
{"NOTE_FFOR", unix.NOTE_FFOR},
{"NOTE_FILE_POLL", unix.NOTE_FILE_POLL},
{"NOTE_FORK", unix.NOTE_FORK},
{"NOTE_LINK", unix.NOTE_LINK},
{"NOTE_LOWAT", unix.NOTE_LOWAT},
{"NOTE_MSECONDS", unix.NOTE_MSECONDS},
{"NOTE_NSECONDS", unix.NOTE_NSECONDS},
{"NOTE_OPEN", unix.NOTE_OPEN},
{"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK},
{"NOTE_PDATAMASK", unix.NOTE_PDATAMASK},
{"NOTE_READ", unix.NOTE_READ},
{"NOTE_RENAME", unix.NOTE_RENAME},
{"NOTE_REVOKE", unix.NOTE_REVOKE},
{"NOTE_SECONDS", unix.NOTE_SECONDS},
{"NOTE_TRACK", unix.NOTE_TRACK},
{"NOTE_TRACKERR", unix.NOTE_TRACKERR},
{"NOTE_TRIGGER", unix.NOTE_TRIGGER},
{"NOTE_USECONDS", unix.NOTE_USECONDS},
{"NOTE_WRITE", unix.NOTE_WRITE},
}
@@ -0,0 +1,32 @@
//go:build freebsd || openbsd || netbsd || dragonfly || darwin
package internal
import (
"fmt"
"os"
"strings"
"time"
"golang.org/x/sys/unix"
)
func Debug(name string, kevent *unix.Kevent_t) {
mask := uint32(kevent.Fflags)
var (
l []string
unknown = mask
)
for _, n := range names {
if mask&n.m == n.m {
l = append(l, n.n)
unknown ^= n.m
}
}
if unknown > 0 {
l = append(l, fmt.Sprintf("0x%x", unknown))
}
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s %10d:%-60s → %q\n",
time.Now().Format("15:04:05.000000000"), mask, strings.Join(l, " | "), name)
}
@@ -0,0 +1,56 @@
package internal
import (
"fmt"
"os"
"strings"
"time"
"golang.org/x/sys/unix"
)
func Debug(name string, mask, cookie uint32) {
names := []struct {
n string
m uint32
}{
{"IN_ACCESS", unix.IN_ACCESS},
{"IN_ATTRIB", unix.IN_ATTRIB},
{"IN_CLOSE", unix.IN_CLOSE},
{"IN_CLOSE_NOWRITE", unix.IN_CLOSE_NOWRITE},
{"IN_CLOSE_WRITE", unix.IN_CLOSE_WRITE},
{"IN_CREATE", unix.IN_CREATE},
{"IN_DELETE", unix.IN_DELETE},
{"IN_DELETE_SELF", unix.IN_DELETE_SELF},
{"IN_IGNORED", unix.IN_IGNORED},
{"IN_ISDIR", unix.IN_ISDIR},
{"IN_MODIFY", unix.IN_MODIFY},
{"IN_MOVE", unix.IN_MOVE},
{"IN_MOVED_FROM", unix.IN_MOVED_FROM},
{"IN_MOVED_TO", unix.IN_MOVED_TO},
{"IN_MOVE_SELF", unix.IN_MOVE_SELF},
{"IN_OPEN", unix.IN_OPEN},
{"IN_Q_OVERFLOW", unix.IN_Q_OVERFLOW},
{"IN_UNMOUNT", unix.IN_UNMOUNT},
}
var (
l []string
unknown = mask
)
for _, n := range names {
if mask&n.m == n.m {
l = append(l, n.n)
unknown ^= n.m
}
}
if unknown > 0 {
l = append(l, fmt.Sprintf("0x%x", unknown))
}
var c string
if cookie > 0 {
c = fmt.Sprintf("(cookie: %d) ", cookie)
}
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s %-30s → %s%q\n",
time.Now().Format("15:04:05.000000000"), strings.Join(l, "|"), c, name)
}
@@ -0,0 +1,25 @@
package internal
import "golang.org/x/sys/unix"
var names = []struct {
n string
m uint32
}{
{"NOTE_ATTRIB", unix.NOTE_ATTRIB},
{"NOTE_CHILD", unix.NOTE_CHILD},
{"NOTE_DELETE", unix.NOTE_DELETE},
{"NOTE_EXEC", unix.NOTE_EXEC},
{"NOTE_EXIT", unix.NOTE_EXIT},
{"NOTE_EXTEND", unix.NOTE_EXTEND},
{"NOTE_FORK", unix.NOTE_FORK},
{"NOTE_LINK", unix.NOTE_LINK},
{"NOTE_LOWAT", unix.NOTE_LOWAT},
{"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK},
{"NOTE_PDATAMASK", unix.NOTE_PDATAMASK},
{"NOTE_RENAME", unix.NOTE_RENAME},
{"NOTE_REVOKE", unix.NOTE_REVOKE},
{"NOTE_TRACK", unix.NOTE_TRACK},
{"NOTE_TRACKERR", unix.NOTE_TRACKERR},
{"NOTE_WRITE", unix.NOTE_WRITE},
}
@@ -0,0 +1,28 @@
package internal
import "golang.org/x/sys/unix"
var names = []struct {
n string
m uint32
}{
{"NOTE_ATTRIB", unix.NOTE_ATTRIB},
// {"NOTE_CHANGE", unix.NOTE_CHANGE}, // Not on 386?
{"NOTE_CHILD", unix.NOTE_CHILD},
{"NOTE_DELETE", unix.NOTE_DELETE},
{"NOTE_EOF", unix.NOTE_EOF},
{"NOTE_EXEC", unix.NOTE_EXEC},
{"NOTE_EXIT", unix.NOTE_EXIT},
{"NOTE_EXTEND", unix.NOTE_EXTEND},
{"NOTE_FORK", unix.NOTE_FORK},
{"NOTE_LINK", unix.NOTE_LINK},
{"NOTE_LOWAT", unix.NOTE_LOWAT},
{"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK},
{"NOTE_PDATAMASK", unix.NOTE_PDATAMASK},
{"NOTE_RENAME", unix.NOTE_RENAME},
{"NOTE_REVOKE", unix.NOTE_REVOKE},
{"NOTE_TRACK", unix.NOTE_TRACK},
{"NOTE_TRACKERR", unix.NOTE_TRACKERR},
{"NOTE_TRUNCATE", unix.NOTE_TRUNCATE},
{"NOTE_WRITE", unix.NOTE_WRITE},
}
@@ -0,0 +1,45 @@
package internal
import (
"fmt"
"os"
"strings"
"time"
"golang.org/x/sys/unix"
)
func Debug(name string, mask int32) {
names := []struct {
n string
m int32
}{
{"FILE_ACCESS", unix.FILE_ACCESS},
{"FILE_MODIFIED", unix.FILE_MODIFIED},
{"FILE_ATTRIB", unix.FILE_ATTRIB},
{"FILE_TRUNC", unix.FILE_TRUNC},
{"FILE_NOFOLLOW", unix.FILE_NOFOLLOW},
{"FILE_DELETE", unix.FILE_DELETE},
{"FILE_RENAME_TO", unix.FILE_RENAME_TO},
{"FILE_RENAME_FROM", unix.FILE_RENAME_FROM},
{"UNMOUNTED", unix.UNMOUNTED},
{"MOUNTEDOVER", unix.MOUNTEDOVER},
{"FILE_EXCEPTION", unix.FILE_EXCEPTION},
}
var (
l []string
unknown = mask
)
for _, n := range names {
if mask&n.m == n.m {
l = append(l, n.n)
unknown ^= n.m
}
}
if unknown > 0 {
l = append(l, fmt.Sprintf("0x%x", unknown))
}
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s %10d:%-30s → %q\n",
time.Now().Format("15:04:05.000000000"), mask, strings.Join(l, " | "), name)
}
@@ -0,0 +1,40 @@
package internal
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/sys/windows"
)
func Debug(name string, mask uint32) {
names := []struct {
n string
m uint32
}{
{"FILE_ACTION_ADDED", windows.FILE_ACTION_ADDED},
{"FILE_ACTION_REMOVED", windows.FILE_ACTION_REMOVED},
{"FILE_ACTION_MODIFIED", windows.FILE_ACTION_MODIFIED},
{"FILE_ACTION_RENAMED_OLD_NAME", windows.FILE_ACTION_RENAMED_OLD_NAME},
{"FILE_ACTION_RENAMED_NEW_NAME", windows.FILE_ACTION_RENAMED_NEW_NAME},
}
var (
l []string
unknown = mask
)
for _, n := range names {
if mask&n.m == n.m {
l = append(l, n.n)
unknown ^= n.m
}
}
if unknown > 0 {
l = append(l, fmt.Sprintf("0x%x", unknown))
}
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s %-65s → %q\n",
time.Now().Format("15:04:05.000000000"), strings.Join(l, " | "), filepath.ToSlash(name))
}
+31
View File
@@ -0,0 +1,31 @@
//go:build freebsd
package internal
import (
"syscall"
"golang.org/x/sys/unix"
)
var (
ErrSyscallEACCES = syscall.EACCES
ErrUnixEACCES = unix.EACCES
)
var maxfiles uint64
func SetRlimit() {
// Go 1.19 will do this automatically: https://go-review.googlesource.com/c/go/+/393354/
var l syscall.Rlimit
err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &l)
if err == nil && l.Cur != l.Max {
l.Cur = l.Max
syscall.Setrlimit(syscall.RLIMIT_NOFILE, &l)
}
maxfiles = uint64(l.Cur)
}
func Maxfiles() uint64 { return maxfiles }
func Mkfifo(path string, mode uint32) error { return unix.Mkfifo(path, mode) }
func Mknod(path string, mode uint32, dev int) error { return unix.Mknod(path, mode, uint64(dev)) }
+2
View File
@@ -0,0 +1,2 @@
// Package internal contains some helpers.
package internal
+31
View File
@@ -0,0 +1,31 @@
//go:build !windows && !darwin && !freebsd && !plan9
package internal
import (
"syscall"
"golang.org/x/sys/unix"
)
var (
ErrSyscallEACCES = syscall.EACCES
ErrUnixEACCES = unix.EACCES
)
var maxfiles uint64
func SetRlimit() {
// Go 1.19 will do this automatically: https://go-review.googlesource.com/c/go/+/393354/
var l syscall.Rlimit
err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &l)
if err == nil && l.Cur != l.Max {
l.Cur = l.Max
syscall.Setrlimit(syscall.RLIMIT_NOFILE, &l)
}
maxfiles = uint64(l.Cur)
}
func Maxfiles() uint64 { return maxfiles }
func Mkfifo(path string, mode uint32) error { return unix.Mkfifo(path, mode) }
func Mknod(path string, mode uint32, dev int) error { return unix.Mknod(path, mode, dev) }
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package internal
func HasPrivilegesForSymlink() bool {
return true
}
+41
View File
@@ -0,0 +1,41 @@
//go:build windows
package internal
import (
"errors"
"golang.org/x/sys/windows"
)
// Just a dummy.
var (
ErrSyscallEACCES = errors.New("dummy")
ErrUnixEACCES = errors.New("dummy")
)
func SetRlimit() {}
func Maxfiles() uint64 { return 1<<64 - 1 }
func Mkfifo(path string, mode uint32) error { return errors.New("no FIFOs on Windows") }
func Mknod(path string, mode uint32, dev int) error { return errors.New("no device nodes on Windows") }
func HasPrivilegesForSymlink() bool {
var sid *windows.SID
err := windows.AllocateAndInitializeSid(
&windows.SECURITY_NT_AUTHORITY,
2,
windows.SECURITY_BUILTIN_DOMAIN_RID,
windows.DOMAIN_ALIAS_RID_ADMINS,
0, 0, 0, 0, 0, 0,
&sid)
if err != nil {
return false
}
defer windows.FreeSid(sid)
token := windows.Token(0)
member, err := token.IsMember(sid)
if err != nil {
return false
}
return member || token.IsElevated()
}
+64
View File
@@ -0,0 +1,64 @@
package fsnotify
import "sync"
type shared struct {
Events chan Event
Errors chan error
done chan struct{}
mu sync.Mutex
}
func newShared(ev chan Event, errs chan error) *shared {
return &shared{
Events: ev,
Errors: errs,
done: make(chan struct{}),
}
}
// Returns true if the event was sent, or false if watcher is closed.
func (w *shared) sendEvent(e Event) bool {
if e.Op == 0 {
return true
}
select {
case <-w.done:
return false
case w.Events <- e:
return true
}
}
// Returns true if the error was sent, or false if watcher is closed.
func (w *shared) sendError(err error) bool {
if err == nil {
return true
}
select {
case <-w.done:
return false
case w.Errors <- err:
return true
}
}
func (w *shared) isClosed() bool {
select {
case <-w.done:
return true
default:
return false
}
}
// Mark as closed; returns true if it was already closed.
func (w *shared) close() bool {
w.mu.Lock()
defer w.mu.Unlock()
if w.isClosed() {
return true
}
close(w.done)
return false
}
+3
View File
@@ -0,0 +1,3 @@
checks = ['all',
'-U1000', # Don't complain about unused functions.
]
+7
View File
@@ -0,0 +1,7 @@
//go:build freebsd || openbsd || netbsd || dragonfly
package fsnotify
import "golang.org/x/sys/unix"
const openMode = unix.O_NONBLOCK | unix.O_RDONLY | unix.O_CLOEXEC
+8
View File
@@ -0,0 +1,8 @@
//go:build darwin
package fsnotify
import "golang.org/x/sys/unix"
// note: this constant is not defined on BSD
const openMode = unix.O_EVTONLY | unix.O_CLOEXEC
+11
View File
@@ -0,0 +1,11 @@
# See https://cirrus-ci.org/guide/FreeBSD/
freebsd_instance:
image_family: freebsd-14-3
task:
name: Test on FreeBSD
install_script: pkg install -y go125 dbus
test_script: |
/usr/local/etc/rc.d/dbus onestart && \
eval `dbus-launch --sh-syntax` && \
go125 test -v ./...
+13
View File
@@ -0,0 +1,13 @@
version: "2"
linters:
enable:
- unconvert
- unparam
exclusions:
presets:
- std-error-handling
formatters:
enable:
- gofumpt
+50
View File
@@ -0,0 +1,50 @@
# How to Contribute
## Getting Started
- Fork the repository on GitHub
- Read the [README](README.markdown) for build and test instructions
- Play with the project, submit bugs, submit patches!
## Contribution Flow
This is a rough outline of what a contributor's workflow looks like:
- Create a topic branch from where you want to base your work (usually master).
- Make commits of logical units.
- Make sure your commit messages are in the proper format (see below).
- Push your changes to a topic branch in your fork of the repository.
- Make sure the tests pass, and add any new tests as appropriate.
- Submit a pull request to the original repository.
Thanks for your contributions!
### Format of the Commit Message
We follow a rough convention for commit messages that is designed to answer two
questions: what changed and why. The subject line should feature the what and
the body of the commit should describe the why.
```
scripts: add the test-cluster command
this uses tmux to setup a test cluster that you can easily kill and
start for debugging.
Fixes #38
```
The format can be described more formally as follows:
```
<subsystem>: <what changed>
<BLANK LINE>
<why this change was made>
<BLANK LINE>
<footer>
```
The first line is the subject and should be no longer than 70 characters, the
second line is always blank, and other lines should be wrapped at 80 characters.
This allows the message to be easier to read on GitHub as well as in various
git tools.
+25
View File
@@ -0,0 +1,25 @@
Copyright (c) 2013, Georg Reinke (<guelfey at gmail dot com>), Google
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. 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.
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.
+3
View File
@@ -0,0 +1,3 @@
Brandon Philips <brandon@ifup.org> (@philips)
Brian Waldon <brian@waldon.cc> (@bcwaldon)
John Southworth <jsouthwo@brocade.com> (@jsouthworth)
+47
View File
@@ -0,0 +1,47 @@
![Build Status](https://github.com/godbus/dbus/workflows/Go/badge.svg)
dbus
----
dbus is a simple library that implements native Go client bindings for the
D-Bus message bus system.
### Features
* Complete native implementation of the D-Bus message protocol
* Go-like API (channels for signals / asynchronous method calls, Goroutine-safe connections)
* Subpackages that help with the introspection / property interfaces
### Installation
This packages requires Go 1.20 or later. It can be installed by running the command below:
```
go get github.com/godbus/dbus/v5
```
### Usage
The complete package documentation and some simple examples are available at
[pkg.go.dev](https://pkg.go.dev/github.com/godbus/dbus/v5). Also, the
[_examples](https://github.com/godbus/dbus/tree/master/_examples) directory
gives a short overview over the basic usage.
#### Projects using godbus
- [fyne](https://github.com/fyne-io/fyne) a cross platform GUI in Go inspired by Material Design.
- [fynedesk](https://github.com/fyne-io/fynedesk) a full desktop environment for Linux/Unix using Fyne.
- [go-bluetooth](https://github.com/muka/go-bluetooth) provides a bluetooth client over bluez dbus API.
- [iwd](https://github.com/shibumi/iwd) go bindings for the internet wireless daemon "iwd".
- [notify](https://github.com/esiqveland/notify) provides desktop notifications over dbus into a library.
- [playerbm](https://github.com/altdesktop/playerbm) a bookmark utility for media players.
- [rpic](https://github.com/stephenhu/rpic) lightweight web app and RESTful API for managing a Raspberry Pi
Please note that the API is considered unstable for now and may change without
further notice.
### License
go.dbus is available under the Simplified BSD License; see LICENSE for the full
text.
Nearly all of the credit for this library goes to github.com/guelfey/go.dbus.
+13
View File
@@ -0,0 +1,13 @@
# Security Policy
## Supported Versions
Security updates are applied only to the latest release.
## Reporting a Vulnerability
If you have discovered a security vulnerability in this project, please report it privately. **Do not disclose it as a public issue.** This gives us time to work with you to fix the issue before public exposure, reducing the chance that the exploit will be used before a patch is released.
Please disclose it at [security advisory](https://github.com/godbus/dbus/security/advisories/new).
This project is maintained by a team of volunteers on a reasonable-effort basis. As such, vulnerabilities will be disclosed in a best effort base.
+257
View File
@@ -0,0 +1,257 @@
package dbus
import (
"bufio"
"bytes"
"errors"
"io"
"os"
"strconv"
)
// AuthStatus represents the Status of an authentication mechanism.
type AuthStatus byte
const (
// AuthOk signals that authentication is finished; the next command
// from the server should be an OK.
AuthOk AuthStatus = iota
// AuthContinue signals that additional data is needed; the next command
// from the server should be a DATA.
AuthContinue
// AuthError signals an error; the server sent invalid data or some
// other unexpected thing happened and the current authentication
// process should be aborted.
AuthError
)
type authState byte
const (
waitingForData authState = iota
waitingForOk
waitingForReject
)
// Auth defines the behaviour of an authentication mechanism.
type Auth interface {
// Return the name of the mechanism, the argument to the first AUTH command
// and the next status.
FirstData() (name, resp []byte, status AuthStatus)
// Process the given DATA command, and return the argument to the DATA
// command and the next status. If len(resp) == 0, no DATA command is sent.
HandleData(data []byte) (resp []byte, status AuthStatus)
}
// Auth authenticates the connection, trying the given list of authentication
// mechanisms (in that order). If nil is passed, the EXTERNAL and
// DBUS_COOKIE_SHA1 mechanisms are tried for the current user. For private
// connections, this method must be called before sending any messages to the
// bus. Auth must not be called on shared connections.
func (conn *Conn) Auth(methods []Auth) error {
if methods == nil {
uid := strconv.Itoa(os.Geteuid())
methods = getDefaultAuthMethods(uid)
}
in := bufio.NewReader(conn.transport)
err := conn.SendNullByte()
if err != nil {
return err
}
err = authWriteLine(conn.transport, []byte("AUTH"))
if err != nil {
return err
}
s, err := authReadLine(in)
if err != nil {
return err
}
if len(s) < 2 || !bytes.Equal(s[0], []byte("REJECTED")) {
return errors.New("dbus: authentication protocol error")
}
s = s[1:]
for _, v := range s {
for _, m := range methods {
if name, _, status := m.FirstData(); bytes.Equal(v, name) {
var ok bool
err = authWriteLine(conn.transport, []byte("AUTH"), v)
if err != nil {
return err
}
switch status {
case AuthOk:
ok, err = conn.tryAuth(m, waitingForOk, in)
case AuthContinue:
ok, err = conn.tryAuth(m, waitingForData, in)
default:
panic("dbus: invalid authentication status")
}
if err != nil {
return err
}
if ok {
if conn.transport.SupportsUnixFDs() {
err = authWriteLine(conn, []byte("NEGOTIATE_UNIX_FD"))
if err != nil {
return err
}
line, err := authReadLine(in)
if err != nil {
return err
}
switch {
case bytes.Equal(line[0], []byte("AGREE_UNIX_FD")):
conn.EnableUnixFDs()
conn.unixFD = true
case bytes.Equal(line[0], []byte("ERROR")):
default:
return errors.New("dbus: authentication protocol error")
}
}
err = authWriteLine(conn.transport, []byte("BEGIN"))
if err != nil {
return err
}
go conn.inWorker()
return nil
}
}
}
}
return errors.New("dbus: authentication failed")
}
// tryAuth tries to authenticate with m as the mechanism, using state as the
// initial authState and in for reading input. It returns (true, nil) on
// success, (false, nil) on a REJECTED and (false, someErr) if some other
// error occurred.
func (conn *Conn) tryAuth(m Auth, state authState, in *bufio.Reader) (bool, error) {
for {
s, err := authReadLine(in)
if err != nil {
return false, err
}
switch {
case state == waitingForData && string(s[0]) == "DATA":
if len(s) != 2 {
err = authWriteLine(conn.transport, []byte("ERROR"))
if err != nil {
return false, err
}
continue
}
data, status := m.HandleData(s[1])
switch status {
case AuthOk, AuthContinue:
if len(data) != 0 {
err = authWriteLine(conn.transport, []byte("DATA"), data)
if err != nil {
return false, err
}
}
if status == AuthOk {
state = waitingForOk
}
case AuthError:
err = authWriteLine(conn.transport, []byte("ERROR"))
if err != nil {
return false, err
}
}
case state == waitingForData && string(s[0]) == "REJECTED":
return false, nil
case state == waitingForData && string(s[0]) == "ERROR":
err = authWriteLine(conn.transport, []byte("CANCEL"))
if err != nil {
return false, err
}
state = waitingForReject
case state == waitingForData && string(s[0]) == "OK":
if len(s) != 2 {
err = authWriteLine(conn.transport, []byte("CANCEL"))
if err != nil {
return false, err
}
state = waitingForReject
} else {
conn.uuid = string(s[1])
return true, nil
}
case state == waitingForData:
err = authWriteLine(conn.transport, []byte("ERROR"))
if err != nil {
return false, err
}
case state == waitingForOk && string(s[0]) == "OK":
if len(s) != 2 {
err = authWriteLine(conn.transport, []byte("CANCEL"))
if err != nil {
return false, err
}
state = waitingForReject
} else {
conn.uuid = string(s[1])
return true, nil
}
case state == waitingForOk && string(s[0]) == "DATA":
err = authWriteLine(conn.transport, []byte("DATA"))
if err != nil {
return false, nil
}
case state == waitingForOk && string(s[0]) == "REJECTED":
return false, nil
case state == waitingForOk && string(s[0]) == "ERROR":
err = authWriteLine(conn.transport, []byte("CANCEL"))
if err != nil {
return false, err
}
state = waitingForReject
case state == waitingForOk:
err = authWriteLine(conn.transport, []byte("ERROR"))
if err != nil {
return false, err
}
case state == waitingForReject && string(s[0]) == "REJECTED":
return false, nil
case state == waitingForReject:
return false, errors.New("dbus: authentication protocol error")
default:
panic("dbus: invalid auth state")
}
}
}
// authReadLine reads a line and separates it into its fields.
func authReadLine(in *bufio.Reader) ([][]byte, error) {
data, err := in.ReadBytes('\n')
if err != nil {
return nil, err
}
data = bytes.TrimSuffix(data, []byte("\r\n"))
return bytes.Split(data, []byte{' '}), nil
}
// authWriteLine writes the given line in the authentication protocol format
// (elements of data separated by a " " and terminated by "\r\n").
func authWriteLine(out io.Writer, data ...[]byte) error {
buf := make([]byte, 0)
for i, v := range data {
buf = append(buf, v...)
if i != len(data)-1 {
buf = append(buf, ' ')
}
}
buf = append(buf, '\r')
buf = append(buf, '\n')
n, err := out.Write(buf)
if err != nil {
return err
}
if n != len(buf) {
return io.ErrUnexpectedEOF
}
return nil
}
+16
View File
@@ -0,0 +1,16 @@
package dbus
// AuthAnonymous returns an Auth that uses the ANONYMOUS mechanism.
func AuthAnonymous() Auth {
return &authAnonymous{}
}
type authAnonymous struct{}
func (a *authAnonymous) FirstData() (name, resp []byte, status AuthStatus) {
return []byte("ANONYMOUS"), nil, AuthOk
}
func (a *authAnonymous) HandleData(data []byte) (resp []byte, status AuthStatus) {
return nil, AuthError
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package dbus
func getDefaultAuthMethods(user string) []Auth {
return []Auth{AuthExternal(user)}
}

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