cloudflared-mirror/orchestration/local_watcher_test.go

304 lines
8.0 KiB
Go

package orchestration
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
"github.com/cloudflare/cloudflared/config"
"github.com/cloudflare/cloudflared/ingress"
)
func TestNewLocalConfigWatcher(t *testing.T) {
log := zerolog.Nop()
orchestrator := createTestOrchestrator(t)
watcher := NewLocalConfigWatcher(orchestrator, "/tmp/config.yaml", &log)
require.NotNil(t, watcher)
require.Equal(t, "/tmp/config.yaml", watcher.configPath)
require.Equal(t, int32(localConfigVersionStart), watcher.Version())
}
func TestLocalConfigWatcher_ReloadConfig(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
configContent := `
tunnel: test-tunnel-id
ingress:
- hostname: example.com
service: http://localhost:8080
- service: http_status:404
`
err := os.WriteFile(configPath, []byte(configContent), 0o600)
require.NoError(t, err)
log := zerolog.Nop()
orchestrator := createTestOrchestrator(t)
watcher := NewLocalConfigWatcher(orchestrator, configPath, &log)
watcher.ReloadConfig()
require.Equal(t, int32(localConfigVersionStart+1), watcher.Version())
}
func TestLocalConfigWatcher_ReloadConfig_InvalidYAML(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
err := os.WriteFile(configPath, []byte("invalid: yaml: ["), 0o600)
require.NoError(t, err)
log := zerolog.Nop()
orchestrator := createTestOrchestrator(t)
watcher := NewLocalConfigWatcher(orchestrator, configPath, &log)
watcher.ReloadConfig()
require.Equal(t, int32(localConfigVersionStart), watcher.Version())
}
func TestLocalConfigWatcher_ReloadConfig_InvalidIngress(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
// Missing catch-all rule (no empty hostname at end)
configContent := `
tunnel: test-tunnel-id
ingress:
- hostname: example.com
service: http://localhost:8080
`
err := os.WriteFile(configPath, []byte(configContent), 0o600)
require.NoError(t, err)
log := zerolog.Nop()
orchestrator := createTestOrchestrator(t)
watcher := NewLocalConfigWatcher(orchestrator, configPath, &log)
watcher.ReloadConfig()
require.Equal(t, int32(localConfigVersionStart), watcher.Version())
}
func TestLocalConfigWatcher_WatcherItemDidChange(t *testing.T) {
log := zerolog.Nop()
orchestrator := createTestOrchestrator(t)
watcher := NewLocalConfigWatcher(orchestrator, "/tmp/config.yaml", &log)
watcher.WatcherItemDidChange("/tmp/config.yaml")
select {
case <-watcher.reloadChan:
default:
t.Fatal("Expected reload channel to receive signal")
}
}
func TestLocalConfigWatcher_WatcherItemDidChange_NonBlocking(t *testing.T) {
log := zerolog.Nop()
orchestrator := createTestOrchestrator(t)
watcher := NewLocalConfigWatcher(orchestrator, "/tmp/config.yaml", &log)
watcher.reloadChan <- struct{}{}
watcher.WatcherItemDidChange("/tmp/config.yaml")
select {
case <-watcher.reloadChan:
default:
t.Fatal("Expected reload channel to have signal")
}
}
func TestLocalConfigWatcher_Run_ManualReload(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
configContent := `
tunnel: test-tunnel-id
ingress:
- hostname: example.com
service: http://localhost:8080
- service: http_status:404
`
err := os.WriteFile(configPath, []byte(configContent), 0o600)
require.NoError(t, err)
log := zerolog.Nop()
orchestrator := createTestOrchestrator(t)
watcher := NewLocalConfigWatcher(orchestrator, configPath, &log)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
reloadC := make(chan struct{}, 1)
readyC := watcher.Run(ctx, reloadC)
<-readyC // Wait until watcher is ready
// Send reload signal
reloadC <- struct{}{}
// Wait for version to increment
require.Eventually(t, func() bool {
return watcher.Version() >= localConfigVersionStart+1
}, 2*time.Second, 10*time.Millisecond, "version should be incremented after reload")
}
func TestLocalConfigWatcher_Run_FileChange(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
configContent := `
tunnel: test-tunnel-id
ingress:
- hostname: example.com
service: http://localhost:8080
- service: http_status:404
`
err := os.WriteFile(configPath, []byte(configContent), 0o600)
require.NoError(t, err)
log := zerolog.Nop()
orchestrator := createTestOrchestrator(t)
watcher := NewLocalConfigWatcher(orchestrator, configPath, &log)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
reloadC := make(chan struct{}, 1)
readyC := watcher.Run(ctx, reloadC)
<-readyC // Wait until watcher is ready
newConfigContent := `
tunnel: test-tunnel-id
ingress:
- hostname: new-example.com
service: http://localhost:9090
- service: http_status:404
`
// Write the config file. We may need to write multiple times because fsnotify
// may not have started watching yet. We write with increasing delays to allow
// the debounce timer (500ms) to fire between writes.
written := false
for range 5 {
err = os.WriteFile(configPath, []byte(newConfigContent), 0o600)
require.NoError(t, err)
written = true
// Wait longer than debounce interval to allow reload to happen
time.Sleep(600 * time.Millisecond)
if watcher.Version() >= localConfigVersionStart+1 {
break
}
}
require.True(t, written, "should have written config file")
require.GreaterOrEqual(t, watcher.Version(), int32(localConfigVersionStart+1), "version should be incremented after file change")
}
func TestLocalConfigWatcher_ConcurrentReloads(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
configContent := `
tunnel: test-tunnel-id
ingress:
- hostname: example.com
service: http://localhost:8080
- service: http_status:404
`
err := os.WriteFile(configPath, []byte(configContent), 0o600)
require.NoError(t, err)
log := zerolog.Nop()
orchestrator := createTestOrchestrator(t)
watcher := NewLocalConfigWatcher(orchestrator, configPath, &log)
// Run multiple concurrent reloads
const numGoroutines = 10
var wg sync.WaitGroup
wg.Add(numGoroutines)
for range numGoroutines {
go func() {
defer wg.Done()
watcher.ReloadConfig()
}()
}
wg.Wait()
// With TryLock, concurrent reloads are skipped if one is already in progress.
// At least one reload should succeed (version >= start+1).
// Due to TryLock skipping, version likely won't reach start+numGoroutines.
finalVersion := watcher.Version()
require.GreaterOrEqual(t, finalVersion, int32(localConfigVersionStart+1),
"At least one reload should have succeeded")
require.LessOrEqual(t, finalVersion, int32(localConfigVersionStart+numGoroutines),
"Version should not exceed expected reloads")
}
func TestLocalConfigWatcher_Run_ContextCancellation(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
configContent := `
tunnel: test-tunnel-id
ingress:
- service: http_status:404
`
err := os.WriteFile(configPath, []byte(configContent), 0o600)
require.NoError(t, err)
log := zerolog.Nop()
orchestrator := createTestOrchestrator(t)
watcher := NewLocalConfigWatcher(orchestrator, configPath, &log)
ctx, cancel := context.WithCancel(context.Background())
reloadC := make(chan struct{}, 1)
readyC := watcher.Run(ctx, reloadC)
<-readyC
// Cancel context and verify watcher stops without panic or hang
cancel()
time.Sleep(50 * time.Millisecond)
}
func createTestOrchestrator(t *testing.T) *Orchestrator {
t.Helper()
log := zerolog.Nop()
originDialer := ingress.NewOriginDialer(ingress.OriginConfig{
DefaultDialer: ingress.NewDialer(ingress.WarpRoutingConfig{
ConnectTimeout: config.CustomDuration{Duration: 1 * time.Second},
TCPKeepAlive: config.CustomDuration{Duration: 15 * time.Second},
MaxActiveFlows: 0,
}),
TCPWriteTimeout: 1 * time.Second,
}, &log)
initConfig := &Config{
Ingress: &ingress.Ingress{},
OriginDialerService: originDialer,
}
orchestrator, err := NewOrchestrator(t.Context(), initConfig, nil, []ingress.Rule{}, &log)
require.NoError(t, err)
return orchestrator
}