Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions common.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/proxy"
"github.com/fosrl/newt/websocket"
"github.com/fsnotify/fsnotify"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.zx2c4.com/wireguard/tun/netstack"
Expand Down Expand Up @@ -518,3 +519,61 @@ func sendBlueprint(client *websocket.Client) error {

return nil
}

func watchBlueprintFile(ctx context.Context, filePath string, send func() error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
logger.Error("blueprint watcher: failed to create: %v", err)
return
}
defer watcher.Close()

if err := watcher.Add(filePath); err != nil {
logger.Error("blueprint watcher: failed to watch %s: %v", filePath, err)
return
}

logger.Info("Watching blueprint file for changes: %s", filePath)

var debounce *time.Timer
scheduleSend := func() {
if debounce != nil {
debounce.Stop()
}
debounce = time.AfterFunc(500*time.Millisecond, func() {
logger.Info("Blueprint file changed, resending...")
if err := send(); err != nil {
logger.Error("blueprint watcher: resend failed: %v", err)
}
})
}

for {
select {
case <-ctx.Done():
if debounce != nil {
debounce.Stop()
}
return
case event, ok := <-watcher.Events:
if !ok {
return
}
switch {
case event.Has(fsnotify.Write) || event.Has(fsnotify.Create):
if event.Has(fsnotify.Create) {
_ = watcher.Add(filePath)
}
scheduleSend()
case event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename):
_ = watcher.Add(filePath)
scheduleSend()
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
logger.Error("blueprint watcher: %v", err)
}
}
}
146 changes: 146 additions & 0 deletions common_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package main

import (
"context"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)

func TestWatchBlueprintFile_WriteTriggersSend(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
if err != nil {
t.Fatal(err)
}
f.Close()

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

var calls atomic.Int32
go watchBlueprintFile(ctx, f.Name(), func() error {
calls.Add(1)
return nil
})

time.Sleep(50 * time.Millisecond)

if err := os.WriteFile(f.Name(), []byte("content"), 0644); err != nil {
t.Fatal(err)
}

time.Sleep(700 * time.Millisecond)

if calls.Load() != 1 {
t.Errorf("expected 1 send call, got %d", calls.Load())
}
}

func TestWatchBlueprintFile_DebounceCoalescesEvents(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
if err != nil {
t.Fatal(err)
}
f.Close()

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

var calls atomic.Int32
go watchBlueprintFile(ctx, f.Name(), func() error {
calls.Add(1)
return nil
})

time.Sleep(50 * time.Millisecond)

for i := 0; i < 5; i++ {
if err := os.WriteFile(f.Name(), []byte("change"), 0644); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
}

time.Sleep(700 * time.Millisecond)

if calls.Load() != 1 {
t.Errorf("expected 1 send call after debounce, got %d", calls.Load())
}
}

func TestWatchBlueprintFile_ContextCancellationStops(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
if err != nil {
t.Fatal(err)
}
f.Close()

ctx, cancel := context.WithCancel(context.Background())

done := make(chan struct{})
go func() {
watchBlueprintFile(ctx, f.Name(), func() error { return nil })
close(done)
}()

time.Sleep(50 * time.Millisecond)
cancel()

select {
case <-done:
case <-time.After(2 * time.Second):
t.Error("watchBlueprintFile did not exit after context cancellation")
}
}

func TestWatchBlueprintFile_AtomicWriteTriggersSend(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "blueprint.yaml")
if err := os.WriteFile(target, []byte("initial"), 0644); err != nil {
t.Fatal(err)
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

var calls atomic.Int32
go watchBlueprintFile(ctx, target, func() error {
calls.Add(1)
return nil
})

time.Sleep(50 * time.Millisecond)

tmp := filepath.Join(dir, "blueprint.yaml.tmp")
if err := os.WriteFile(tmp, []byte("updated"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Rename(tmp, target); err != nil {
t.Fatal(err)
}

time.Sleep(700 * time.Millisecond)

if calls.Load() < 1 {
t.Errorf("expected at least 1 send call after atomic write, got %d", calls.Load())
}
}

func TestWatchBlueprintFile_MissingFileReturnsGracefully(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

done := make(chan struct{})
go func() {
watchBlueprintFile(ctx, "/nonexistent/path/blueprint.yaml", func() error { return nil })
close(done)
}()

select {
case <-done:
case <-time.After(2 * time.Second):
t.Error("watchBlueprintFile did not return for missing file")
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ require (
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/btree v1.1.3 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
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/gaissmai/bart v0.26.0 h1:xOZ57E9hJLBiQaSyeZa9wgWhGuzfGACgqp4BE77OkO0=
github.com/gaissmai/bart v0.26.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
Expand Down
6 changes: 6 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1627,6 +1627,12 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
}
}

if blueprintFile != "" {
go watchBlueprintFile(ctx, blueprintFile, func() error {
return sendBlueprint(client)
})
}

// Wait for context cancellation (from signal or service stop)
<-ctx.Done()

Expand Down