orama/core/pkg/sniproxy/reloader_test.go
anonpenguin23 f8de4af704 feat(sni-router): implement hot-reloading for route configuration
- Add `FileRouteReloader` to watch and atomically update routes from disk
- Refactor `main` to support seamless configuration updates without restarts
- Ensure existing routes are preserved if a reload encounters an error
2026-06-09 09:23:54 +03:00

144 lines
4.3 KiB
Go

package sniproxy
import (
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
"go.uber.org/zap"
)
// feat-41: the SNI router hot-reloads its route table from disk so a namespace's
// cdn/turn routes can be added/removed without restarting the router. These pin
// the initial apply, the hot-reload-on-change path, and the resilience contract
// (a bad source keeps the currently-installed routes serving).
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", p, err)
}
return p
}
func TestFileRouteReloader_appliesInitialRoutes(t *testing.T) {
path := writeFile(t, t.TempDir(), "routes.yaml", "v1")
source := func() ([]Route, Backend, error) {
return []Route{
{Match: "cdn.ns-a.example.com", Backend: Backend{Addr: "127.0.0.1:5349"}},
}, Backend{Addr: "127.0.0.1:8443"}, nil
}
router := NewRouter(Backend{Addr: "unset"})
r := NewFileRouteReloader(path, source, router, zap.NewNop())
if err := r.Apply(); err != nil {
t.Fatalf("Apply: %v", err)
}
if got := len(router.Routes()); got != 1 {
t.Fatalf("want 1 route after initial apply, got %d", got)
}
if b := router.Pick("cdn.ns-a.example.com"); b.Addr != "127.0.0.1:5349" {
t.Errorf("route not installed; Pick gave %q", b.Addr)
}
if router.Fallback().Addr != "127.0.0.1:8443" {
t.Errorf("fallback not installed; got %q", router.Fallback().Addr)
}
}
func TestFileRouteReloader_hotReloadsOnFileChange(t *testing.T) {
path := writeFile(t, t.TempDir(), "routes.yaml", "v1")
var mu sync.Mutex
version := 1
source := func() ([]Route, Backend, error) {
mu.Lock()
defer mu.Unlock()
if version == 1 {
return []Route{{Match: "a.example.com", Backend: Backend{Addr: "127.0.0.1:1"}}},
Backend{Addr: "fb:1"}, nil
}
return []Route{
{Match: "a.example.com", Backend: Backend{Addr: "127.0.0.1:1"}},
{Match: "b.example.com", Backend: Backend{Addr: "127.0.0.1:2"}},
}, Backend{Addr: "fb:2"}, nil
}
router := NewRouter(Backend{Addr: "unset"})
r := NewFileRouteReloader(path, source, router, zap.NewNop())
if err := r.Apply(); err != nil {
t.Fatalf("initial Apply: %v", err)
}
if len(router.Routes()) != 1 {
t.Fatalf("want 1 route initially, got %d", len(router.Routes()))
}
// "Renew": flip the source to v2 and advance the file mtime so the watcher
// detects the change regardless of filesystem timestamp granularity.
mu.Lock()
version = 2
mu.Unlock()
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(path, future, future); err != nil {
t.Fatalf("chtimes: %v", err)
}
stop := make(chan struct{})
defer close(stop)
go r.Watch(5*time.Millisecond, stop)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if len(router.Routes()) == 2 && router.Fallback().Addr == "fb:2" {
return // hot-reloaded
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("routes were not hot-reloaded (have %d routes, fallback %q)",
len(router.Routes()), router.Fallback().Addr)
}
func TestFileRouteReloader_keepsRoutesOnSourceError(t *testing.T) {
path := writeFile(t, t.TempDir(), "routes.yaml", "v1")
var mu sync.Mutex
fail := false
source := func() ([]Route, Backend, error) {
mu.Lock()
defer mu.Unlock()
if fail {
return nil, Backend{}, errors.New("invalid config")
}
return []Route{{Match: "a.example.com", Backend: Backend{Addr: "127.0.0.1:1"}}},
Backend{Addr: "fb:1"}, nil
}
router := NewRouter(Backend{Addr: "unset"})
r := NewFileRouteReloader(path, source, router, zap.NewNop())
if err := r.Apply(); err != nil {
t.Fatalf("initial Apply: %v", err)
}
// Make the source fail, then trigger a reload via an mtime bump.
mu.Lock()
fail = true
mu.Unlock()
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(path, future, future); err != nil {
t.Fatalf("chtimes: %v", err)
}
stop := make(chan struct{})
go r.Watch(5*time.Millisecond, stop)
time.Sleep(200 * time.Millisecond) // let it tick + hit the failing source
close(stop)
if got := len(router.Routes()); got != 1 {
t.Errorf("a failed reload must keep the previous routes; got %d routes", got)
}
if router.Fallback().Addr != "fb:1" {
t.Errorf("a failed reload must keep the previous fallback; got %q", router.Fallback().Addr)
}
}