From 2e00d44cd2bc4aa56ad05eb3370c8f4c20c6394d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 20 Jul 2026 05:52:12 +0100 Subject: [PATCH] Preserve deployment mock settings on reload Contract-Neutral: Preserve deployment-owned PULSE_MOCK environment values when the watched auth env file reloads; no public contract changes. --- internal/config/watcher.go | 63 ++++++++++++++++++++++++++------- internal/config/watcher_test.go | 34 +++++++++++++++--- 2 files changed, 81 insertions(+), 16 deletions(-) diff --git a/internal/config/watcher.go b/internal/config/watcher.go index 7bd298324..802aa878e 100644 --- a/internal/config/watcher.go +++ b/internal/config/watcher.go @@ -43,6 +43,13 @@ type ConfigWatcher struct { onMockReload func() // Callback to trigger backend restart when PULSE_MOCK_* changes onAPITokenReload func() // Callback when API tokens are reloaded from disk pollInterval time.Duration + mockEnvOwned map[string]struct{} + mockEnvFallbacks map[string]mockEnvFallback +} + +type mockEnvFallback struct { + value string + present bool } // NewConfigWatcher creates a new config watcher @@ -92,13 +99,15 @@ func NewConfigWatcher(config *Config) (*ConfigWatcher, error) { } cw := &ConfigWatcher{ - config: config, - envPath: envPath, - apiTokensPath: apiTokensPath, - persistence: persistence, - watcher: watcher, - stopChan: make(chan struct{}), - pollInterval: 5 * time.Second, + config: config, + envPath: envPath, + apiTokensPath: apiTokensPath, + persistence: persistence, + watcher: watcher, + stopChan: make(chan struct{}), + pollInterval: 5 * time.Second, + mockEnvOwned: make(map[string]struct{}), + mockEnvFallbacks: make(map[string]mockEnvFallback), } // Get initial mod times and hash @@ -108,6 +117,11 @@ func NewConfigWatcher(config *Config) (*ConfigWatcher, error) { hash := sha256.Sum256(content) cw.lastEnvHash = hex.EncodeToString(hash[:]) } + if envMap, err := godotenv.Read(envPath); err == nil { + for key := range extractMockEnv(envMap) { + cw.mockEnvOwned[key] = struct{}{} + } + } } if stat, err := watcherOsStat(apiTokensPath); err == nil { cw.apiTokensLastModTime = stat.ModTime() @@ -416,7 +430,6 @@ func (cw *ConfigWatcher) reloadConfig() { // Update auth settings oldAuthUser := cw.config.AuthUser oldAuthPass := cw.config.AuthPass - oldMockEnv := currentMockEnv() newMockEnv := extractMockEnv(envMap) // Apply auth user @@ -449,7 +462,7 @@ func (cw *ConfigWatcher) reloadConfig() { } Mu.Unlock() - mockChanged := applyMockEnv(newMockEnv, oldMockEnv) + mockChanged := cw.applyMockEnv(newMockEnv) if mockChanged { changes = append(changes, "mock runtime updated") } @@ -620,23 +633,49 @@ func extractMockEnv(envMap map[string]string) map[string]string { return mockEnv } -func applyMockEnv(next map[string]string, current map[string]string) bool { +func (cw *ConfigWatcher) applyMockEnv(next map[string]string) bool { + if cw.mockEnvOwned == nil { + cw.mockEnvOwned = make(map[string]struct{}) + } + if cw.mockEnvFallbacks == nil { + cw.mockEnvFallbacks = make(map[string]mockEnvFallback) + } + + current := currentMockEnv() changed := false seen := make(map[string]struct{}, len(next)) for key, value := range next { seen[key] = struct{}{} + if _, owned := cw.mockEnvOwned[key]; !owned { + fallbackValue, fallbackPresent := current[key] + cw.mockEnvFallbacks[key] = mockEnvFallback{ + value: fallbackValue, + present: fallbackPresent, + } + cw.mockEnvOwned[key] = struct{}{} + } if currentValue, ok := current[key]; !ok || currentValue != value { _ = os.Setenv(key, value) changed = true } } - for key := range current { + for key := range cw.mockEnvOwned { if _, ok := seen[key]; ok { continue } - if err := os.Unsetenv(key); err == nil { + fallback, hasFallback := cw.mockEnvFallbacks[key] + currentValue, currentPresent := current[key] + if hasFallback && fallback.present { + if !currentPresent || currentValue != fallback.value { + _ = os.Setenv(key, fallback.value) + changed = true + } + } else if currentPresent { + _ = os.Unsetenv(key) changed = true } + delete(cw.mockEnvOwned, key) + delete(cw.mockEnvFallbacks, key) } if changed { keys := make([]string, 0, len(next)) diff --git a/internal/config/watcher_test.go b/internal/config/watcher_test.go index 401e2c269..7dd388055 100644 --- a/internal/config/watcher_test.go +++ b/internal/config/watcher_test.go @@ -462,6 +462,15 @@ func TestConfigWatcher_ReloadConfig_RemovesMockSetting(t *testing.T) { tempDir := t.TempDir() envPath := filepath.Join(tempDir, ".env") t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) + previousMockValue, hadPreviousMockValue := os.LookupEnv("PULSE_MOCK_TEST") + require.NoError(t, os.Unsetenv("PULSE_MOCK_TEST")) + t.Cleanup(func() { + if hadPreviousMockValue { + _ = os.Setenv("PULSE_MOCK_TEST", previousMockValue) + } else { + _ = os.Unsetenv("PULSE_MOCK_TEST") + } + }) cfg := &Config{} cw, err := NewConfigWatcher(cfg) @@ -477,17 +486,34 @@ func TestConfigWatcher_ReloadConfig_RemovesMockSetting(t *testing.T) { os.Unsetenv("PULSE_MOCK_TEST") } -func TestConfigWatcher_ReloadConfig_MissingEnvRemovesMockSetting(t *testing.T) { +func TestConfigWatcher_ReloadConfig_MissingEnvPreservesProcessMockSetting(t *testing.T) { tempDir := t.TempDir() t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) - os.Setenv("PULSE_MOCK_TEST", "stale") - t.Cleanup(func() { os.Unsetenv("PULSE_MOCK_TEST") }) + t.Setenv("PULSE_MOCK_TEST", "deployment-owned") cw, err := NewConfigWatcher(&Config{}) require.NoError(t, err) cw.reloadConfig() - assert.Equal(t, "", os.Getenv("PULSE_MOCK_TEST")) + assert.Equal(t, "deployment-owned", os.Getenv("PULSE_MOCK_TEST")) +} + +func TestConfigWatcher_ReloadConfig_RestoresProcessMockSettingAfterFileOverride(t *testing.T) { + tempDir := t.TempDir() + envPath := filepath.Join(tempDir, ".env") + t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) + t.Setenv("PULSE_MOCK_TEST", "deployment-owned") + + cw, err := NewConfigWatcher(&Config{}) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(envPath, []byte(`PULSE_MOCK_TEST="file-owned"`), 0644)) + cw.reloadConfig() + require.Equal(t, "file-owned", os.Getenv("PULSE_MOCK_TEST")) + + require.NoError(t, os.WriteFile(envPath, []byte(""), 0644)) + cw.reloadConfig() + assert.Equal(t, "deployment-owned", os.Getenv("PULSE_MOCK_TEST")) } func TestConfigWatcher_ReloadAPITokens_Retries(t *testing.T) {