// Package configreload — общий мини-либ для live-reload конфигов MCP-серверов // (контракт per-tenant config + live-reload, см. docs/ARCHITECTURE.md): // stdlib-only, без внешних зависимостей, поэтому не тащит лишнего. // // Идея: каждый сервер держит свой per-agent конфиг // /.json. Loader[T] читает файл, сверяет контент // по sha256 (единственный источник истины — контент-хэш, а не mtime) и // перечитывает/парсит, только если содержимое изменилось. Загружается лениво // и потокобезопасно. package configreload import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "sync" ) // ErrNotFound — файл конфига отсутствует (или удалён). Такой конфиг следует // трактовать как "нет тенант-конфига" (сервер сам решит фолбэк на дефолт). var ErrNotFound = errors.New("configreload: config file not found") // Loader — ленивый, потокобезопасный загрузчик конфига с last-good. // T — тип распарсенного конфига; parse — билдер из сырых байтов. type Loader[T any] struct { path string parse func([]byte) (T, error) mu sync.Mutex current T hash string got bool parseErr error } // New создаёт загрузчик конфига path с парсером parse. func New[T any](path string, parse func([]byte) (T, error)) *Loader[T] { return &Loader[T]{path: path, parse: parse} } // Path возвращает путь конфига. func (l *Loader[T]) Path() string { return l.path } // Get возвращает актуальный конфиг, перечитывая файл, только если его // содержимое изменилось (sha256). Возвращает: // - (value, nil) — актуальный конфиг; // - (lastGood, err) — файл изменился к невалидному (parseErr) или чтение // дало ошибку: возвращается последний рабочий конфиг + ошибка, чтобы // вызывающий мог залогировать и продолжить со старым; // - (zero, ErrNotFound) — файл отсутствует/удалён (кэш сброшен); // - (zero, err) — прочие ошибки чтения. // // Ключ: контент-хэш — истина. Даже если mtime/размер "не изменились", мы не // пропускаем чтение — файл маленький, а ложь о контенте опаснее корректного // поведения. func (l *Loader[T]) Get() (T, error) { l.mu.Lock() defer l.mu.Unlock() data, err := os.ReadFile(l.path) if err != nil { if os.IsNotExist(err) { // Конфиг удалён — сбрасываем кэш, сообщаем "нет конфига". l.got, l.hash, l.parseErr = false, "", nil var zero T return zero, fmt.Errorf("%w: %s", ErrNotFound, l.path) } return l.lastGood(fmt.Errorf("configreload: read %s: %w", l.path, err)) } sum := sha256.Sum256(data) hash := hex.EncodeToString(sum[:]) if l.got && hash == l.hash { // Содержимое не изменилось — быстрый путь. Если последний парсинг // был с ошибкой, возвращаем last-good (не затираем его). return l.current, l.parseErr } val, perr := l.parse(data) if perr != nil { // Файл изменился к невалидному содержимому: сохраняем last-good, // фиксируем ошибку парсинга (следующий Get с тем же хэшем вернёт её). l.parseErr = perr return l.lastGood(fmt.Errorf("configreload: parse %s: %w", l.path, perr)) } l.current = val l.hash = hash l.got = true l.parseErr = nil return val, nil } // lastGood возвращает последний рабочий конфиг (если был) вместе с err, чтобы // вызывающий мог залогировать и продолжить работу со старым значением. func (l *Loader[T]) lastGood(err error) (T, error) { if l.got { return l.current, err } var zero T return zero, err }