diff --git a/common.go b/common.go index 5fe0645..6bc2ccf 100644 --- a/common.go +++ b/common.go @@ -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" @@ -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) + } + } +} diff --git a/common_test.go b/common_test.go new file mode 100644 index 0000000..c1664f0 --- /dev/null +++ b/common_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod index 2aa8f5e..f6e7617 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index d345b1d..89433ce 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index 9c373b0..757a3ac 100644 --- a/main.go +++ b/main.go @@ -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()