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.
This commit is contained in:
rcourtman
2026-07-20 05:52:12 +01:00
parent 96fce4cbcb
commit 2e00d44cd2
2 changed files with 81 additions and 16 deletions
+51 -12
View File
@@ -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))
+30 -4
View File
@@ -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) {