Files
Maksim Totmin c398045664
ci / test (push) Failing after 5s
ci / lint (push) Failing after 4s
chore: initial release v0.1.0
forge-toolkit: хелперы для разработки MCP-серверов на Go.

- toolkit: args/schema/result/env/probe/server
- toolkit/validate: валидаторы синтаксиса
- configreload: live-reload конфига по контент-хэшу
- docs: ARCHITECTURE, quickstart, mcp-contract
- template/ и examples/hello-tool/
- CI (Gitea Actions), лицензия Apache-2.0
2026-09-14 00:23:08 +07:00

105 lines
4.5 KiB
Go

// Package configreload — общий мини-либ для live-reload конфигов MCP-серверов
// (контракт per-tenant config + live-reload, см. docs/ARCHITECTURE.md):
// stdlib-only, без внешних зависимостей, поэтому не тащит лишнего.
//
// Идея: каждый сервер держит свой per-agent конфиг
// <FORGE_TENANT_CONFIG>/<tool>.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
}