feat(config): notify reload hooks after a settings update
A settings update replaced the in-memory configuration but nothing told the code that derives state from it. The logger is the first such consumer: its debug flag is captured once at startup by postLog.SetDebugMode, so toggling system.debugMode at runtime changed what config.IsDebugMode() reported while DEBUG lines stayed filtered by the value from boot. Add a hook registry to the config package. OnReload registers a callback and every writer of config.json runs the registered hooks with the configuration now in effect. It lives in config so packages that already depend on it (the logger, and later the controller manager) can react without config importing them back, which would be an import cycle. Hooks run after settingsLock is released, not inside it. They will rebuild controllers once those support reloading, which can wait on a network call, and settingsLock is also held by the node tracker's background save — running a hook under the lock would stall node registration behind a settings edit. runSettingsUpdate now owns that lock/notify sequence for all four writer paths (UpdateSettings and the three webhook endpoint helpers). A panicking hook is logged and skipped: by then the new configuration is on disk and published, so reporting the write as failed would be a lie, and the hooks registered after the broken one still need to run.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nukumizu-backend/global"
|
||||
)
|
||||
|
||||
// swapReloadHooks replaces the registered reload hooks for the duration of a
|
||||
// test and restores the previous set afterwards, so the package-level registry
|
||||
// cannot leak into another test.
|
||||
func swapReloadHooks(t *testing.T, hooks ...func(*Config)) {
|
||||
t.Helper()
|
||||
|
||||
reloadHooksMu.Lock()
|
||||
original := reloadHooks
|
||||
reloadHooks = hooks
|
||||
reloadHooksMu.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
reloadHooksMu.Lock()
|
||||
reloadHooks = original
|
||||
reloadHooksMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestReloadHookRunsOnlyForGlobalSettings(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
|
||||
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{}`)
|
||||
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{}`)
|
||||
|
||||
var seen []bool
|
||||
swapReloadHooks(t, func(cfg *Config) {
|
||||
seen = append(seen, cfg.System.DebugMode)
|
||||
})
|
||||
|
||||
// A config.json update runs the hook, with the values just written.
|
||||
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
|
||||
"system": map[string]interface{}{"debugMode": true},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSettings(global): %v", err)
|
||||
}
|
||||
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("hook ran %d times for a config.json update, want 1", len(seen))
|
||||
}
|
||||
if !seen[0] {
|
||||
t.Error("hook received a configuration without the updated debugMode")
|
||||
}
|
||||
|
||||
// The other two files reload a singleton that callers read at the point of
|
||||
// use, so they have nothing to notify.
|
||||
for _, settingsType := range []string{SettingBotUserConfig, SettingBotNodeConfig} {
|
||||
if err := UpdateSettings(settingsType, map[string]interface{}{
|
||||
"unused": map[string]interface{}{"event_reply": true},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSettings(%s): %v", settingsType, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(seen) != 1 {
|
||||
t.Errorf("hook ran %d times after updates to the other settings files, want 1", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadHookIsolatesPanic(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
|
||||
|
||||
reached := false
|
||||
swapReloadHooks(t,
|
||||
func(*Config) { panic("hook under test") },
|
||||
func(*Config) { reached = true },
|
||||
)
|
||||
|
||||
// The configuration is already on disk and published by the time hooks run,
|
||||
// so a broken hook must not turn a successful write into a failed request.
|
||||
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
|
||||
"system": map[string]interface{}{"debugMode": true},
|
||||
}); err != nil {
|
||||
t.Fatalf("a panicking hook must not fail the settings write: %v", err)
|
||||
}
|
||||
if !reached {
|
||||
t.Error("a panicking hook stopped the hooks registered after it")
|
||||
}
|
||||
|
||||
// The write itself must still have landed.
|
||||
cfg := Current()
|
||||
if cfg == nil || !cfg.System.DebugMode {
|
||||
t.Error("the settings write did not take effect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadHookSeesEveryWriterPath pins the invariant that each writer of
|
||||
// config.json notifies, not just /api/settings/set: the webhook endpoint
|
||||
// helpers go through the same channel.
|
||||
func TestReloadHookSeesEveryWriterPath(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{
|
||||
"webhook": { "endpoints": {} }
|
||||
}`)
|
||||
|
||||
var seen int
|
||||
swapReloadHooks(t, func(*Config) { seen++ })
|
||||
|
||||
if err := AddWebhookEndpoint("example", map[string]interface{}{
|
||||
"enabled": true,
|
||||
"token": "secret",
|
||||
"notifyPipes": []interface{}{"ntfy"},
|
||||
}); err != nil {
|
||||
t.Fatalf("AddWebhookEndpoint: %v", err)
|
||||
}
|
||||
if seen != 1 {
|
||||
t.Errorf("hook ran %d times after adding an endpoint, want 1", seen)
|
||||
}
|
||||
|
||||
if err := ModifyWebhookEndpoint("example", map[string]interface{}{
|
||||
"enabled": false,
|
||||
}); err != nil {
|
||||
t.Fatalf("ModifyWebhookEndpoint: %v", err)
|
||||
}
|
||||
if seen != 2 {
|
||||
t.Errorf("hook ran %d times after modifying an endpoint, want 2", seen)
|
||||
}
|
||||
|
||||
if err := DeleteWebhookEndpoint("example"); err != nil {
|
||||
t.Fatalf("DeleteWebhookEndpoint: %v", err)
|
||||
}
|
||||
if seen != 3 {
|
||||
t.Errorf("hook ran %d times after deleting an endpoint, want 3", seen)
|
||||
}
|
||||
|
||||
// A rejected write changes nothing, so it must not notify either.
|
||||
if err := DeleteWebhookEndpoint("ghost"); err == nil {
|
||||
t.Error("deleting an unknown endpoint should fail")
|
||||
}
|
||||
if seen != 3 {
|
||||
t.Errorf("hook ran for a rejected write (%d notifications, want 3)", seen)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user