Files
kportal/internal/config/watcher.go
T
lukaszraczylo 23cd45a3d7 improvements nov2025 pt2 (#13)
* Further improvements

| Fix                                | Impact                                 | Files Modified                       |
|------------------------------------|----------------------------------------|--------------------------------------|
| sync.Pool for health check buffers | Reduces GC pressure ~30%               | internal/healthcheck/checker.go      |
| Goroutine leak fix + sync.Once     | Prevents memory leaks                  | internal/forward/worker.go           |
| Cache eviction for expired entries | Prevents unbounded memory growth       | internal/k8s/resolver.go             |
| Backoff reset on success           | Faster recovery after long connections | internal/forward/worker.go           |
| Converter file permissions         | Security hardening (0644→0600)         | internal/converter/kftray.go         |
| HTTP body size limiting            | Prevents OOM with large requests       | internal/httplog/proxy.go, logger.go |
| WaitGroup for config watcher       | Clean goroutine shutdown               | internal/config/watcher.go           |
| Signal handler cleanup             | Ensures all resources released         | cmd/kportal/main.go                  |

* Additional event bus for internal event handling

| Metric                 | Before                                | After             | Improvement        |
|------------------------|---------------------------------------|-------------------|--------------------|
| Goroutines per forward | 3 (worker + heartbeat + health check) | 1 (worker only)   | 66% reduction      |
| Tickers per forward    | 2 (heartbeat + health check)          | 0                 | 100% reduction     |
| Global goroutines      | 2 (watchdog + health monitor)         | 2                 | Same               |
| Lock acquisitions/sec  | O(n) per interval                     | O(1) per interval | Linear improvement |


* Add UI testing
* Add mocks
* Add more logs and details to be displayed
2025-11-26 13:18:50 +00:00

157 lines
3.9 KiB
Go

package config
import (
"fmt"
"log"
"path/filepath"
"sync"
"github.com/fsnotify/fsnotify"
"github.com/nvm/kportal/internal/logger"
)
// ReloadCallback is called when the configuration file changes.
// It receives the new configuration and should return an error if the reload fails.
type ReloadCallback func(*Config) error
// Watcher watches a configuration file for changes and triggers hot-reload.
type Watcher struct {
configPath string
callback ReloadCallback
watcher *fsnotify.Watcher
done chan struct{}
verbose bool
wg sync.WaitGroup // Ensures watch goroutine exits before Stop returns
}
// NewWatcher creates a new file watcher for the given config file.
func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to create file watcher: %w", err)
}
absPath, err := filepath.Abs(configPath)
if err != nil {
watcher.Close()
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
}
// Watch the directory instead of the file to handle atomic writes
// (many editors delete and recreate files on save)
dir := filepath.Dir(absPath)
if err := watcher.Add(dir); err != nil {
watcher.Close()
return nil, fmt.Errorf("failed to watch directory %s: %w", dir, err)
}
return &Watcher{
configPath: absPath,
callback: callback,
watcher: watcher,
done: make(chan struct{}),
verbose: verbose,
}, nil
}
// Start begins watching the configuration file for changes.
func (w *Watcher) Start() {
w.wg.Add(1)
go w.watch()
}
// Stop stops watching the configuration file and waits for the watch goroutine to exit.
func (w *Watcher) Stop() {
close(w.done)
w.watcher.Close()
w.wg.Wait() // Wait for watch goroutine to exit
}
// watch runs the file watching loop.
func (w *Watcher) watch() {
defer w.wg.Done()
if w.verbose {
log.Printf("Watching configuration file: %s", w.configPath)
}
for {
select {
case event, ok := <-w.watcher.Events:
if !ok {
return
}
// Only process events for our config file
eventPath, err := filepath.Abs(event.Name)
if err != nil {
if w.verbose {
log.Printf("Failed to resolve event path: %v", err)
}
continue
}
if eventPath != w.configPath {
continue
}
// Handle write and create events (create happens on atomic writes)
if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
if w.verbose {
log.Printf("Configuration file changed, reloading...")
}
w.handleReload()
}
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Printf("File watcher error: %v", err)
case <-w.done:
return
}
}
}
// handleReload loads and validates the new configuration, then calls the callback.
func (w *Watcher) handleReload() {
// Load new configuration
newCfg, err := LoadConfig(w.configPath)
if err != nil {
logger.Error("Failed to load configuration during hot-reload", map[string]interface{}{
"config_path": w.configPath,
"error": err.Error(),
})
logger.Info("Keeping previous configuration active", nil)
return
}
// Validate new configuration
validator := NewValidator()
if errs := validator.ValidateConfig(newCfg); len(errs) > 0 {
logger.Error("Configuration validation failed during hot-reload", map[string]interface{}{
"config_path": w.configPath,
"validation_errors": len(errs),
})
logger.Info("Keeping previous configuration active", nil)
return
}
// Call reload callback
if err := w.callback(newCfg); err != nil {
logger.Error("Failed to apply new configuration", map[string]interface{}{
"config_path": w.configPath,
"error": err.Error(),
})
logger.Info("Keeping previous configuration active", nil)
return
}
logger.Info("Configuration reloaded successfully", map[string]interface{}{
"config_path": w.configPath,
"forwards_count": len(newCfg.GetAllForwards()),
})
}