mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
yangerd: Initial
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -129,6 +129,7 @@ BR2_PACKAGE_NETD=y
|
||||
BR2_PACKAGE_CONFD_TEST_MODE=y
|
||||
BR2_PACKAGE_GENCERT=y
|
||||
BR2_PACKAGE_STATD=y
|
||||
BR2_PACKAGE_YANGERD=y
|
||||
BR2_PACKAGE_FACTORY=y
|
||||
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
|
||||
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
|
||||
|
||||
@@ -181,6 +181,7 @@ BR2_PACKAGE_CONFD=y
|
||||
BR2_PACKAGE_NETD=y
|
||||
BR2_PACKAGE_GENCERT=y
|
||||
BR2_PACKAGE_STATD=y
|
||||
BR2_PACKAGE_YANGERD=y
|
||||
BR2_PACKAGE_FACTORY=y
|
||||
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
|
||||
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/curios-httpd/Config.in"
|
||||
source "$BR2_EXTERNAL_INFIX_PATH/package/curios-nftables/Config.in"
|
||||
source "$BR2_EXTERNAL_INFIX_PATH/package/gencert/Config.in"
|
||||
source "$BR2_EXTERNAL_INFIX_PATH/package/statd/Config.in"
|
||||
source "$BR2_EXTERNAL_INFIX_PATH/package/yangerd/Config.in"
|
||||
source "$BR2_EXTERNAL_INFIX_PATH/package/factory/Config.in"
|
||||
source "$BR2_EXTERNAL_INFIX_PATH/package/faux/Config.in"
|
||||
source "$BR2_EXTERNAL_INFIX_PATH/package/finit/Config.in"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
config BR2_PACKAGE_YANGERD
|
||||
bool "yangerd"
|
||||
depends on BR2_PACKAGE_HOST_GO_TARGET_ARCH_SUPPORTS
|
||||
help
|
||||
Operational data daemon for YANG/NETCONF/RESTCONF.
|
||||
Replaces Python yanger scripts with a persistent Go daemon
|
||||
serving operational data over a Unix socket IPC protocol.
|
||||
@@ -0,0 +1,3 @@
|
||||
service <!> name:yangerd log:prio:daemon.notice,tag:yangerd \
|
||||
env:-/etc/default/yangerd \
|
||||
[S12345] yangerd -- Operational data daemon
|
||||
@@ -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))
|
||||
@@ -0,0 +1,3 @@
|
||||
# Build artifacts
|
||||
yangerd
|
||||
yangerctl
|
||||
@@ -0,0 +1,27 @@
|
||||
Copyright (c) 2025 The KernelKit Authors
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of copyright holders nor the names of
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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, ðObj); 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
||||
Chris Howey <howeyc@gmail.com> <chris@howey.me>
|
||||
Nathan Youngman <git@nathany.com> <4566+nathany@users.noreply.github.com>
|
||||
+602
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) }
|
||||
+57
@@ -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},
|
||||
}
|
||||
+33
@@ -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},
|
||||
}
|
||||
+42
@@ -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},
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
+56
@@ -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)
|
||||
}
|
||||
+25
@@ -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},
|
||||
}
|
||||
+28
@@ -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},
|
||||
}
|
||||
+45
@@ -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)
|
||||
}
|
||||
+40
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
||||
// Package internal contains some helpers.
|
||||
package internal
|
||||
+31
@@ -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
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package internal
|
||||
|
||||
func HasPrivilegesForSymlink() bool {
|
||||
return true
|
||||
}
|
||||
+41
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
checks = ['all',
|
||||
'-U1000', # Don't complain about unused functions.
|
||||
]
|
||||
+7
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,13 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- unconvert
|
||||
- unparam
|
||||
exclusions:
|
||||
presets:
|
||||
- std-error-handling
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
+50
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
Brandon Philips <brandon@ifup.org> (@philips)
|
||||
Brian Waldon <brian@waldon.cc> (@bcwaldon)
|
||||
John Southworth <jsouthwo@brocade.com> (@jsouthworth)
|
||||
+47
@@ -0,0 +1,47 @@
|
||||

|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user