mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 19:41:17 +00:00
469 lines
12 KiB
Go
469 lines
12 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/google/uuid"
|
|
"github.com/joho/godotenv"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// ConfigWatcher monitors the .env file for changes and updates runtime config
|
|
type ConfigWatcher struct {
|
|
config *Config
|
|
envPath string
|
|
mockEnvPath string
|
|
apiTokensPath string
|
|
watcher *fsnotify.Watcher
|
|
stopChan chan struct{}
|
|
lastModTime time.Time
|
|
mockLastModTime time.Time
|
|
apiTokensLastModTime time.Time
|
|
mu sync.RWMutex
|
|
onMockReload func() // Callback to trigger backend restart
|
|
}
|
|
|
|
// NewConfigWatcher creates a new config watcher
|
|
func NewConfigWatcher(config *Config) (*ConfigWatcher, error) {
|
|
// Determine env file path
|
|
envPath := filepath.Join(config.ConfigPath, ".env")
|
|
if config.ConfigPath == "" {
|
|
envPath = "/etc/pulse/.env"
|
|
}
|
|
|
|
// Check for Docker environment
|
|
if _, err := os.Stat("/data/.env"); err == nil {
|
|
envPath = "/data/.env"
|
|
}
|
|
|
|
// Determine mock.env path - skip in Docker or if directory doesn't exist
|
|
mockEnvPath := ""
|
|
isDocker := os.Getenv("PULSE_DOCKER") == "true"
|
|
mockDir := "/opt/pulse"
|
|
if !isDocker {
|
|
if stat, err := os.Stat(mockDir); err == nil && stat.IsDir() {
|
|
mockEnvPath = filepath.Join(mockDir, "mock.env")
|
|
}
|
|
}
|
|
|
|
watcher, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
apiTokensPath := filepath.Join(filepath.Dir(envPath), "api_tokens.json")
|
|
|
|
cw := &ConfigWatcher{
|
|
config: config,
|
|
envPath: envPath,
|
|
mockEnvPath: mockEnvPath,
|
|
apiTokensPath: apiTokensPath,
|
|
watcher: watcher,
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
|
|
// Get initial mod times
|
|
if stat, err := os.Stat(envPath); err == nil {
|
|
cw.lastModTime = stat.ModTime()
|
|
}
|
|
if mockEnvPath != "" {
|
|
if stat, err := os.Stat(mockEnvPath); err == nil {
|
|
cw.mockLastModTime = stat.ModTime()
|
|
}
|
|
}
|
|
if stat, err := os.Stat(apiTokensPath); err == nil {
|
|
cw.apiTokensLastModTime = stat.ModTime()
|
|
}
|
|
|
|
return cw, nil
|
|
}
|
|
|
|
// SetMockReloadCallback sets the callback function to trigger when mock.env changes
|
|
func (cw *ConfigWatcher) SetMockReloadCallback(callback func()) {
|
|
cw.mu.Lock()
|
|
defer cw.mu.Unlock()
|
|
cw.onMockReload = callback
|
|
}
|
|
|
|
// Start begins watching the config file
|
|
func (cw *ConfigWatcher) Start() error {
|
|
// Watch the directory for .env
|
|
dir := filepath.Dir(cw.envPath)
|
|
err := cw.watcher.Add(dir)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("path", dir).Msg("Failed to watch config directory")
|
|
}
|
|
|
|
// Also watch the mock.env directory if it's configured (not in Docker)
|
|
if cw.mockEnvPath != "" {
|
|
mockDir := filepath.Dir(cw.mockEnvPath)
|
|
if err := cw.watcher.Add(mockDir); err != nil {
|
|
log.Warn().Err(err).Str("path", mockDir).Msg("Failed to watch mock.env directory")
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
log.Warn().Msg("Falling back to polling for config changes")
|
|
go cw.pollForChanges()
|
|
return nil
|
|
}
|
|
|
|
go cw.watchForChanges()
|
|
logEvent := log.Info().Str("env_path", cw.envPath).Str("api_tokens_path", cw.apiTokensPath)
|
|
if cw.mockEnvPath != "" {
|
|
logEvent = logEvent.Str("mock_env_path", cw.mockEnvPath)
|
|
}
|
|
logEvent.Msg("Started watching config files for changes")
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the config watcher
|
|
func (cw *ConfigWatcher) Stop() {
|
|
select {
|
|
case <-cw.stopChan:
|
|
// Already stopped
|
|
return
|
|
default:
|
|
close(cw.stopChan)
|
|
}
|
|
cw.watcher.Close()
|
|
}
|
|
|
|
// ReloadConfig manually triggers a config reload (e.g., from SIGHUP)
|
|
func (cw *ConfigWatcher) ReloadConfig() {
|
|
cw.reloadConfig()
|
|
}
|
|
|
|
// watchForChanges handles fsnotify events
|
|
func (cw *ConfigWatcher) watchForChanges() {
|
|
for {
|
|
select {
|
|
case event, ok := <-cw.watcher.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Check if the event is for our .env file
|
|
if filepath.Base(event.Name) == ".env" || event.Name == cw.envPath {
|
|
// Debounce - wait a bit for write to complete
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
if event.Op&(fsnotify.Write|fsnotify.Create) != 0 {
|
|
log.Info().Str("event", event.Op.String()).Msg("Detected .env file change")
|
|
cw.reloadConfig()
|
|
}
|
|
}
|
|
|
|
if cw.apiTokensPath != "" && (filepath.Base(event.Name) == filepath.Base(cw.apiTokensPath) || event.Name == cw.apiTokensPath) {
|
|
// Debounce
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
if event.Op&(fsnotify.Write|fsnotify.Create) != 0 {
|
|
log.Info().Str("event", event.Op.String()).Msg("Detected API token file change")
|
|
cw.reloadAPITokens()
|
|
}
|
|
}
|
|
|
|
// Check if the event is for mock.env (only if mock.env watching is enabled)
|
|
if cw.mockEnvPath != "" && (filepath.Base(event.Name) == "mock.env" || event.Name == cw.mockEnvPath) {
|
|
// Debounce - wait a bit for write to complete
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
if event.Op&(fsnotify.Write|fsnotify.Create) != 0 {
|
|
log.Info().Str("event", event.Op.String()).Msg("Detected mock.env file change")
|
|
cw.reloadMockConfig()
|
|
}
|
|
}
|
|
|
|
case err, ok := <-cw.watcher.Errors:
|
|
if !ok {
|
|
return
|
|
}
|
|
log.Error().Err(err).Msg("Config watcher error")
|
|
|
|
case <-cw.stopChan:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// pollForChanges is a fallback that polls for changes
|
|
func (cw *ConfigWatcher) pollForChanges() {
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
// Check .env
|
|
if stat, err := os.Stat(cw.envPath); err == nil {
|
|
if stat.ModTime().After(cw.lastModTime) {
|
|
log.Info().Msg("Detected .env file change via polling")
|
|
cw.lastModTime = stat.ModTime()
|
|
cw.reloadConfig()
|
|
}
|
|
}
|
|
|
|
// Check mock.env (only if mock.env watching is enabled)
|
|
if cw.mockEnvPath != "" {
|
|
if stat, err := os.Stat(cw.mockEnvPath); err == nil {
|
|
if stat.ModTime().After(cw.mockLastModTime) {
|
|
log.Info().Msg("Detected mock.env file change via polling")
|
|
cw.mockLastModTime = stat.ModTime()
|
|
cw.reloadMockConfig()
|
|
}
|
|
}
|
|
}
|
|
|
|
if cw.apiTokensPath != "" {
|
|
if stat, err := os.Stat(cw.apiTokensPath); err == nil {
|
|
if stat.ModTime().After(cw.apiTokensLastModTime) {
|
|
log.Info().Msg("Detected API token file change via polling")
|
|
cw.apiTokensLastModTime = stat.ModTime()
|
|
cw.reloadAPITokens()
|
|
}
|
|
}
|
|
}
|
|
|
|
case <-cw.stopChan:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// reloadConfig reloads the config from the .env file
|
|
func (cw *ConfigWatcher) reloadConfig() {
|
|
cw.mu.Lock()
|
|
defer cw.mu.Unlock()
|
|
|
|
// Load the .env file
|
|
envMap, err := godotenv.Read(cw.envPath)
|
|
if err != nil {
|
|
// File might not exist, which is fine (no auth)
|
|
if !os.IsNotExist(err) {
|
|
log.Error().Err(err).Msg("Failed to read .env file")
|
|
return
|
|
}
|
|
envMap = make(map[string]string)
|
|
}
|
|
|
|
// Track what changed
|
|
var changes []string
|
|
|
|
// Update auth settings
|
|
oldAuthUser := cw.config.AuthUser
|
|
oldAuthPass := cw.config.AuthPass
|
|
oldTokenHashes := cw.config.ActiveAPITokenHashes()
|
|
existingByHash := make(map[string]APITokenRecord, len(cw.config.APITokens))
|
|
for _, record := range cw.config.APITokens {
|
|
existingByHash[record.Hash] = record.Clone()
|
|
}
|
|
|
|
// Apply auth user
|
|
newUser := strings.Trim(envMap["PULSE_AUTH_USER"], "'\"")
|
|
if newUser != oldAuthUser {
|
|
cw.config.AuthUser = newUser
|
|
if newUser == "" {
|
|
changes = append(changes, "auth user removed")
|
|
} else if oldAuthUser == "" {
|
|
changes = append(changes, "auth user added")
|
|
} else {
|
|
changes = append(changes, "auth user updated")
|
|
}
|
|
}
|
|
|
|
// Apply auth password
|
|
newPass := strings.Trim(envMap["PULSE_AUTH_PASS"], "'\"")
|
|
if newPass != oldAuthPass {
|
|
cw.config.AuthPass = newPass
|
|
if newPass == "" {
|
|
changes = append(changes, "auth password removed")
|
|
} else if oldAuthPass == "" {
|
|
changes = append(changes, "auth password added")
|
|
} else {
|
|
changes = append(changes, "auth password updated")
|
|
}
|
|
}
|
|
|
|
// Apply API tokens if present in .env (legacy support)
|
|
rawTokens := make([]string, 0, 4)
|
|
if raw, ok := envMap["API_TOKENS"]; ok {
|
|
raw = strings.Trim(raw, "'\"")
|
|
if raw != "" {
|
|
parts := strings.Split(raw, ",")
|
|
for _, part := range parts {
|
|
token := strings.TrimSpace(part)
|
|
if token != "" {
|
|
rawTokens = append(rawTokens, token)
|
|
}
|
|
}
|
|
} else {
|
|
// Explicit empty list clears tokens
|
|
rawTokens = []string{}
|
|
}
|
|
}
|
|
if raw, ok := envMap["API_TOKEN"]; ok {
|
|
raw = strings.Trim(raw, "'\"")
|
|
rawTokens = append(rawTokens, raw)
|
|
}
|
|
|
|
if len(rawTokens) > 0 {
|
|
seen := make(map[string]struct{}, len(rawTokens))
|
|
newRecords := make([]APITokenRecord, 0, len(rawTokens))
|
|
for _, tokenValue := range rawTokens {
|
|
tokenValue = strings.TrimSpace(tokenValue)
|
|
if tokenValue == "" {
|
|
continue
|
|
}
|
|
|
|
hashed := tokenValue
|
|
prefix := tokenPrefix(tokenValue)
|
|
suffix := tokenSuffix(tokenValue)
|
|
if !auth.IsAPITokenHashed(tokenValue) {
|
|
hashed = auth.HashAPIToken(tokenValue)
|
|
prefix = tokenPrefix(tokenValue)
|
|
suffix = tokenSuffix(tokenValue)
|
|
}
|
|
|
|
if _, exists := seen[hashed]; exists {
|
|
continue
|
|
}
|
|
seen[hashed] = struct{}{}
|
|
|
|
if existing, ok := existingByHash[hashed]; ok {
|
|
newRecords = append(newRecords, existing)
|
|
} else {
|
|
newRecords = append(newRecords, APITokenRecord{
|
|
ID: uuid.NewString(),
|
|
Name: "Environment token",
|
|
Hash: hashed,
|
|
Prefix: prefix,
|
|
Suffix: suffix,
|
|
CreatedAt: time.Now().UTC(),
|
|
})
|
|
}
|
|
}
|
|
|
|
cw.config.APITokens = newRecords
|
|
cw.config.SortAPITokens()
|
|
cw.config.APITokenEnabled = len(newRecords) > 0
|
|
|
|
newHashes := cw.config.ActiveAPITokenHashes()
|
|
if !reflect.DeepEqual(oldTokenHashes, newHashes) {
|
|
switch {
|
|
case len(newHashes) == 0:
|
|
changes = append(changes, "API tokens removed")
|
|
case len(oldTokenHashes) == 0:
|
|
changes = append(changes, "API tokens added")
|
|
default:
|
|
changes = append(changes, "API tokens updated")
|
|
}
|
|
|
|
if globalPersistence != nil {
|
|
if err := globalPersistence.SaveAPITokens(cw.config.APITokens); err != nil {
|
|
log.Error().Err(err).Msg("Failed to persist API tokens from .env reload")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// REMOVED: POLLING_INTERVAL from .env - now ONLY in system.json
|
|
// This prevents confusion and ensures single source of truth
|
|
|
|
// Log changes
|
|
if len(changes) > 0 {
|
|
log.Info().
|
|
Strs("changes", changes).
|
|
Bool("has_auth", cw.config.AuthUser != "" && cw.config.AuthPass != "").
|
|
Bool("has_token", cw.config.HasAPITokens()).
|
|
Msg("Applied .env file changes to runtime config")
|
|
} else {
|
|
log.Debug().Msg("No relevant changes detected in .env file")
|
|
}
|
|
}
|
|
|
|
func (cw *ConfigWatcher) reloadAPITokens() {
|
|
cw.mu.Lock()
|
|
defer cw.mu.Unlock()
|
|
|
|
if globalPersistence == nil {
|
|
log.Warn().Msg("Config persistence unavailable; cannot reload API tokens")
|
|
return
|
|
}
|
|
|
|
tokens, err := globalPersistence.LoadAPITokens()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload API tokens")
|
|
return
|
|
}
|
|
|
|
cw.config.APITokens = tokens
|
|
cw.config.SortAPITokens()
|
|
cw.config.APITokenEnabled = len(tokens) > 0
|
|
|
|
if cw.apiTokensPath != "" {
|
|
if stat, err := os.Stat(cw.apiTokensPath); err == nil {
|
|
cw.apiTokensLastModTime = stat.ModTime()
|
|
}
|
|
}
|
|
|
|
log.Info().Int("count", len(tokens)).Msg("Reloaded API tokens from disk")
|
|
}
|
|
|
|
// reloadMockConfig handles mock.env file changes
|
|
func (cw *ConfigWatcher) reloadMockConfig() {
|
|
// Skip if mock.env watching is disabled (Docker environment)
|
|
if cw.mockEnvPath == "" {
|
|
return
|
|
}
|
|
|
|
cw.mu.Lock()
|
|
callback := cw.onMockReload
|
|
cw.mu.Unlock()
|
|
|
|
// Load the mock.env file to update environment variables
|
|
envMap, err := godotenv.Read(cw.mockEnvPath)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
log.Error().Err(err).Msg("Failed to read mock.env file")
|
|
return
|
|
}
|
|
log.Warn().Msg("mock.env file not found")
|
|
return
|
|
}
|
|
|
|
// Load local overrides if they exist
|
|
mockEnvLocalPath := cw.mockEnvPath + ".local"
|
|
if localEnv, err := godotenv.Read(mockEnvLocalPath); err == nil {
|
|
// Merge local overrides into envMap
|
|
for key, value := range localEnv {
|
|
envMap[key] = value
|
|
}
|
|
log.Debug().Str("path", mockEnvLocalPath).Msg("Loaded mock.env.local overrides")
|
|
}
|
|
|
|
// Update environment variables for the mock package to read
|
|
for key, value := range envMap {
|
|
if strings.HasPrefix(key, "PULSE_MOCK_") {
|
|
os.Setenv(key, value)
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Str("path", cw.mockEnvPath).
|
|
Interface("config", envMap).
|
|
Msg("Reloaded mock.env configuration")
|
|
|
|
// Trigger callback to restart backend if set
|
|
if callback != nil {
|
|
log.Info().Msg("Triggering backend restart due to mock.env change")
|
|
go callback()
|
|
}
|
|
}
|