66639e83f7
The fsnotify-based config watcher does not work reliably when the config file is bind-mounted into a Docker container as an individual file, and mishandles k8s ConfigMap projections (atomically swapped symlinks). Replace it with a small os.Stat-polling watcher and add SIGHUP as an explicit reload signal. - new proxy/configwatcher package: 2s os.Stat poller, follows symlinks, fires on mtime/size change and on missing -> present transitions - SIGHUP triggers reload unconditionally (works without --watch-config) via the same ConfigFileChangedEvent pipeline so the UI sees identical state transitions - watcher goroutine now exits cleanly on shutdown via a context - drop github.com/fsnotify/fsnotify dependency fixes #682
192 lines
5.2 KiB
Go
192 lines
5.2 KiB
Go
package configwatcher
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const testInterval = 25 * time.Millisecond
|
|
|
|
// startWatcher launches w.Run in a goroutine and returns a function that
|
|
// cancels the context and waits for Run to return.
|
|
func startWatcher(t *testing.T, w *Watcher) func() {
|
|
t.Helper()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
w.Run(ctx)
|
|
close(done)
|
|
}()
|
|
return func() {
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("watcher did not stop within 2s of cancel")
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitForCount blocks until counter reaches want or timeout elapses.
|
|
func waitForCount(t *testing.T, counter *int64, want int64, timeout time.Duration) bool {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if atomic.LoadInt64(counter) >= want {
|
|
return true
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestWatcher_NoFireOnBaseline(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.yaml")
|
|
require.NoError(t, os.WriteFile(path, []byte("a"), 0o644))
|
|
|
|
var n int64
|
|
stop := startWatcher(t, &Watcher{
|
|
Path: path,
|
|
Interval: testInterval,
|
|
OnChange: func() { atomic.AddInt64(&n, 1) },
|
|
})
|
|
defer stop()
|
|
|
|
time.Sleep(testInterval * 5)
|
|
require.Equal(t, int64(0), atomic.LoadInt64(&n), "baseline poll must not fire")
|
|
}
|
|
|
|
func TestWatcher_DetectsModTimeChange(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.yaml")
|
|
require.NoError(t, os.WriteFile(path, []byte("a"), 0o644))
|
|
|
|
// Force a known baseline mtime.
|
|
base := time.Now().Add(-1 * time.Hour).Truncate(time.Second)
|
|
require.NoError(t, os.Chtimes(path, base, base))
|
|
|
|
var n int64
|
|
stop := startWatcher(t, &Watcher{
|
|
Path: path,
|
|
Interval: testInterval,
|
|
OnChange: func() { atomic.AddInt64(&n, 1) },
|
|
})
|
|
defer stop()
|
|
|
|
// Let the baseline settle.
|
|
time.Sleep(testInterval * 2)
|
|
|
|
// Bump mtime well above the baseline so low-resolution filesystems still notice.
|
|
require.NoError(t, os.Chtimes(path, base.Add(10*time.Second), base.Add(10*time.Second)))
|
|
|
|
require.True(t, waitForCount(t, &n, 1, time.Second), "callback should fire after mtime change")
|
|
}
|
|
|
|
func TestWatcher_DetectsSizeChangeWithSameModTime(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.yaml")
|
|
require.NoError(t, os.WriteFile(path, []byte("a"), 0o644))
|
|
|
|
fi, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
originalMtime := fi.ModTime()
|
|
|
|
var n int64
|
|
stop := startWatcher(t, &Watcher{
|
|
Path: path,
|
|
Interval: testInterval,
|
|
OnChange: func() { atomic.AddInt64(&n, 1) },
|
|
})
|
|
defer stop()
|
|
time.Sleep(testInterval * 2)
|
|
|
|
require.NoError(t, os.WriteFile(path, []byte("aaaaa"), 0o644))
|
|
// Reset mtime back to the original so size is the only signal.
|
|
require.NoError(t, os.Chtimes(path, originalMtime, originalMtime))
|
|
|
|
require.True(t, waitForCount(t, &n, 1, time.Second), "callback should fire on size change")
|
|
}
|
|
|
|
func TestWatcher_SymlinkTargetSwap(t *testing.T) {
|
|
dir := t.TempDir()
|
|
targetA := filepath.Join(dir, "targetA")
|
|
targetB := filepath.Join(dir, "targetB")
|
|
link := filepath.Join(dir, "config.yaml")
|
|
|
|
require.NoError(t, os.WriteFile(targetA, []byte("AAAA"), 0o644))
|
|
require.NoError(t, os.WriteFile(targetB, []byte("BBBBBBBB"), 0o644))
|
|
|
|
if err := os.Symlink(targetA, link); err != nil {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skipf("symlink creation requires privilege on Windows: %v", err)
|
|
}
|
|
t.Fatalf("os.Symlink: %v", err)
|
|
}
|
|
|
|
var n int64
|
|
stop := startWatcher(t, &Watcher{
|
|
Path: link,
|
|
Interval: testInterval,
|
|
OnChange: func() { atomic.AddInt64(&n, 1) },
|
|
})
|
|
defer stop()
|
|
time.Sleep(testInterval * 2)
|
|
|
|
// Atomic symlink swap (k8s ConfigMap pattern): create new symlink at a
|
|
// temp name, then rename over the existing one.
|
|
tmpLink := filepath.Join(dir, "config.yaml.tmp")
|
|
require.NoError(t, os.Symlink(targetB, tmpLink))
|
|
require.NoError(t, os.Rename(tmpLink, link))
|
|
|
|
require.True(t, waitForCount(t, &n, 1, time.Second), "callback should fire after symlink target swap")
|
|
}
|
|
|
|
func TestWatcher_FileMissingThenReturns(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.yaml")
|
|
require.NoError(t, os.WriteFile(path, []byte("a"), 0o644))
|
|
|
|
var n int64
|
|
stop := startWatcher(t, &Watcher{
|
|
Path: path,
|
|
Interval: testInterval,
|
|
OnChange: func() { atomic.AddInt64(&n, 1) },
|
|
})
|
|
defer stop()
|
|
time.Sleep(testInterval * 2)
|
|
|
|
require.NoError(t, os.Remove(path))
|
|
time.Sleep(testInterval * 3)
|
|
require.Equal(t, int64(0), atomic.LoadInt64(&n), "removal alone must not fire")
|
|
|
|
require.NoError(t, os.WriteFile(path, []byte("b"), 0o644))
|
|
require.True(t, waitForCount(t, &n, 1, time.Second), "callback should fire when file returns")
|
|
}
|
|
|
|
func TestWatcher_ContextCancelStopsRun(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.yaml")
|
|
require.NoError(t, os.WriteFile(path, []byte("a"), 0o644))
|
|
|
|
w := &Watcher{Path: path, Interval: testInterval}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { w.Run(ctx); close(done) }()
|
|
|
|
time.Sleep(testInterval * 2)
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Run did not return within 2s of cancel")
|
|
}
|
|
}
|