Files
NanamiAdmin c7642ecca5
Build / ubuntu-latest (push) Failing after 52s
Build / windows-latest (push) Failing after 41m49s
feat(settings): report which saved keys need a restart
Every settings update answered with a bare success, so the console could not
tell a change that took effect at once from one written to the file that the
running program would keep ignoring until it was restarted — the listener
address, the storage paths, the Komari dashboard URL.

Classify the patch instead. config.RestartRequiredKeys expands a patch into the
dot-separated paths of its leaves and intersects them with the settings main
reads before it starts serving, and the settings endpoint returns that list as
data.restartRequired. It is a pure function of the patch, so UpdateSettings
keeps its signature and nothing else has to change; the write succeeds either
way, and the field only says which edits are not live.

Matching is by overlap rather than equality, so a patch that names a section —
replacing it, or deleting it with a null — reports the startup-only keys inside
it, while a sibling subtree like webhook.endpoints is not mistaken for
webhook.enabled.

The result is never nil, so an update with nothing to report serializes as []
rather than null and a client can iterate it without a guard.

The console reads the field in ConfigSection and warns instead of confirming,
naming the keys that are pending; Settings.vue's hints for the Komari URL, the
listen address and the storage paths now say which of their fields is affected
rather than labelling the whole card.
2026-09-28 23:28:47 +08:00

280 lines
9.5 KiB
Go

package config
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"nukumizu-backend/global"
)
// Settings types accepted by the /api/settings/get and /api/settings/set
// endpoints. Each maps 1:1 to a JSON configuration file on disk.
const (
SettingGlobal = "global"
SettingBotUserConfig = "bot_user_config"
SettingBotNodeConfig = "bot_node_config"
)
// ErrUnsupportedSettingsType is returned when a settings type is not one of the
// accepted constants above.
var ErrUnsupportedSettingsType = errors.New("unsupported settings type")
// startupOnlySettings are the config.json keys that are read once before the
// program starts serving and never again: the listener addresses, the paths the
// databases are opened from, and the Komari dashboard its client is built
// against. Editing one writes the file and replaces the in-memory
// configuration, but the running program keeps the old value, so an update that
// touches one is reported back to the caller instead of being silently
// accepted.
//
// Keep this in step with main: these are exactly the settings main reads before
// the HTTP server comes up. Everything else — controllerMethod, networkProxy,
// the message templates, the debug switches — is picked up at runtime.
var startupOnlySettings = []string{
"system.listenAddr",
"system.listenPort",
"webhook.enabled",
"webhook.listenAddr",
"webhook.listenPort",
"komari.dashboardURL",
"dataPath",
"dbPath",
}
// RestartRequiredKeys lists the settings in patch that only take effect at
// startup, as dot-separated paths, in the order startupOnlySettings declares
// them. Only config.json carries such settings; an update to one of the other
// files always reports nothing.
//
// The write itself succeeds either way — this is advice for the user, not a
// rejection. The result is never nil, so a caller can put it straight into a
// JSON response and get [] rather than null.
func RestartRequiredKeys(settingsType string, patch map[string]interface{}) []string {
keys := []string{}
if settingsType != SettingGlobal {
return keys
}
patched := patchPaths(patch)
for _, watched := range startupOnlySettings {
for _, path := range patched {
if pathsOverlap(path, watched) {
keys = append(keys, watched)
break
}
}
}
return keys
}
// patchPaths expands a nested settings patch into the dot-separated paths of its
// leaves. An object is descended into rather than reported, so a patch that only
// names sections still resolves to the keys it changes, and a JSON null is a
// leaf because it deletes the key it names.
func patchPaths(patch map[string]interface{}) []string {
paths := []string{}
var walk func(prefix string, node map[string]interface{})
walk = func(prefix string, node map[string]interface{}) {
for key, value := range node {
path := key
if prefix != "" {
path = prefix + "." + key
}
if nested, ok := value.(map[string]interface{}); ok && nested != nil {
walk(path, nested)
continue
}
paths = append(paths, path)
}
}
walk("", patch)
return paths
}
// pathsOverlap reports whether a patched path and a watched setting can affect
// each other: they are the same key, the patch names something inside the
// watched setting, or the patch names a section the watched setting lives in.
// The last case matters because a patch may replace a whole section, which
// changes every key under it.
func pathsOverlap(patched, watched string) bool {
return patched == watched ||
strings.HasPrefix(patched, watched+".") ||
strings.HasPrefix(watched, patched+".")
}
// settingsLock serializes read-modify-write access to the on-disk configuration
// files so concurrent admin edits (UpdateSettings) and the node tracker's
// background save (SaveBotNodeConfig) cannot lose each other's updates.
var settingsLock sync.RWMutex
// settingsPath resolves a settings type to its JSON configuration file path.
func settingsPath(settingsType string) (string, error) {
switch settingsType {
case SettingGlobal:
return global.ConfigPath.Global, nil
case SettingBotUserConfig:
return global.ConfigPath.BotUserConfig, nil
case SettingBotNodeConfig:
return global.ConfigPath.BotNodeConfig, nil
default:
return "", ErrUnsupportedSettingsType
}
}
// IsValidSettingsType reports whether the given string is one of the accepted
// settings types.
func IsValidSettingsType(settingsType string) bool {
_, err := settingsPath(settingsType)
return err == nil
}
// GetSettings returns the raw JSON of the file backing the given settings type,
// byte-for-byte the same content as the source file. bot_node_config.json is
// optional and auto-generated, so a missing or empty file yields an empty
// object.
func GetSettings(settingsType string) ([]byte, error) {
path, err := settingsPath(settingsType)
if err != nil {
return nil, err
}
settingsLock.RLock()
defer settingsLock.RUnlock()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) && settingsType == SettingBotNodeConfig {
return []byte("{}"), nil
}
return nil, fmt.Errorf("failed to read %s settings file: %w", settingsType, err)
}
if len(bytes.TrimSpace(data)) == 0 {
return []byte("{}"), nil
}
return data, nil
}
// UpdateSettings merges the given partial update into the JSON file backing the
// given settings type and persists the result back to disk. Because the merge is
// recursive, a payload such as {"system":{"debugMode":true}} only touches the
// nested keys it names and leaves every sibling key untouched. After the file is
// written the matching in-memory singleton is reloaded so runtime code observes
// the new values.
func UpdateSettings(settingsType string, patch map[string]interface{}) error {
return runSettingsUpdate(func() (*Config, error) {
return updateSettingsLocked(settingsType, patch)
})
}
// runSettingsUpdate runs fn under settingsLock and then, once the lock is
// released, runs the reload hooks with whatever configuration fn reports (nil
// when the update did not touch config.json).
//
// The hooks deliberately run outside settingsLock. A hook rebuilds controllers,
// which can wait on a network call, while settingsLock is also held by the node
// tracker's background save (SaveBotNodeConfig); holding it across a hook would
// stall node registration behind an unrelated settings edit.
func runSettingsUpdate(fn func() (*Config, error)) error {
cfg, err := func() (*Config, error) {
settingsLock.Lock()
defer settingsLock.Unlock()
return fn()
}()
if err != nil {
return err
}
notifyReload(cfg)
return nil
}
// updateSettingsLocked is UpdateSettings without the locking, for callers that
// need to inspect the loaded configuration and write in one critical section
// (see the incoming webhook endpoint helpers). Callers must hold settingsLock.
//
// It returns the freshly loaded global configuration, or nil when the settings
// type is one of the other files. The caller is responsible for handing that
// value to notifyReload once settingsLock is released — which runSettingsUpdate
// does for every writer.
func updateSettingsLocked(settingsType string, patch map[string]interface{}) (*Config, error) {
path, err := settingsPath(settingsType)
if err != nil {
return nil, err
}
// Start from whatever is already on disk so nothing is dropped. A missing or
// empty file is treated as an empty object.
current := map[string]interface{}{}
data, err := os.ReadFile(path)
if err == nil {
if len(bytes.TrimSpace(data)) > 0 {
if err := json.Unmarshal(data, &current); err != nil {
return nil, fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err)
}
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read existing %s settings file %s: %w", settingsType, path, err)
}
deepMergeSettings(current, patch)
data, err = json.MarshalIndent(current, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal %s settings: %w", settingsType, err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o644); err != nil {
return nil, fmt.Errorf("failed to write %s settings file %s: %w", settingsType, path, err)
}
return reloadSettings(settingsType, path)
}
// deepMergeSettings recursively overlays src onto dst. Object values merge
// key-by-key so partial updates keep sibling keys untouched; arrays and scalars
// always replace the destination value. A JSON null in the payload removes that
// key from dst, giving clients a way to delete entries (members, nodes, map
// rows) through /api/settings/set.
func deepMergeSettings(dst, src map[string]interface{}) {
for key, srcVal := range src {
if srcVal == nil {
delete(dst, key)
continue
}
srcObj, srcIsObj := srcVal.(map[string]interface{})
if srcIsObj {
if dstObj, ok := dst[key].(map[string]interface{}); ok {
deepMergeSettings(dstObj, srcObj)
} else {
dst[key] = srcVal
}
continue
}
dst[key] = srcVal
}
}
// reloadSettings refreshes the in-memory singleton for the given settings type
// so the running program observes the values just persisted to disk. Only
// config.json has a hook-visible reload, so for SettingGlobal it returns the
// configuration now in effect and for the other types it returns nil.
func reloadSettings(settingsType, path string) (*Config, error) {
switch settingsType {
case SettingGlobal:
return LoadGlobalConfig(path)
case SettingBotUserConfig:
_, err := LoadBotUserConfig(path)
return nil, err
case SettingBotNodeConfig:
return nil, LoadBotNodeConfig(path)
default:
return nil, ErrUnsupportedSettingsType
}
}