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
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package configreload
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func write(t *testing.T, dir, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, "cfg.json")
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func parse(cfg map[string]string) func([]byte) (map[string]string, error) {
|
||||
return func(b []byte) (map[string]string, error) {
|
||||
if len(b) == 0 {
|
||||
return nil, errors.New("empty")
|
||||
}
|
||||
return map[string]string{"v": string(b)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_CachesUntilContentChanges(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := write(t, dir, "one")
|
||||
l := New(p, parse(nil))
|
||||
|
||||
if v, err := l.Get(); err != nil || v["v"] != "one" {
|
||||
t.Fatalf("first get: %v %v", v, err)
|
||||
}
|
||||
// Не изменилось — отдаётся из кэша (нет системных вызовов на парсинг).
|
||||
if v, err := l.Get(); err != nil || v["v"] != "one" {
|
||||
t.Fatalf("cached get: %v %v", v, err)
|
||||
}
|
||||
// Изменилось содержимое на то же по размеру → хэш ловит.
|
||||
if err := os.WriteFile(p, []byte("two"), 0o600); err != nil {
|
||||
t.Fatalf("rewrite: %v", err)
|
||||
}
|
||||
if v, err := l.Get(); err != nil || v["v"] != "two" {
|
||||
t.Fatalf("after change: %v %v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_LastGoodOnInvalid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := write(t, dir, "good")
|
||||
l := New(p, parse(nil))
|
||||
if _, err := l.Get(); err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
// Пишем невалидный (пустой) файл — парсер вернёт ошибку => last-good.
|
||||
if err := os.WriteFile(p, []byte(""), 0o600); err != nil {
|
||||
t.Fatalf("write empty: %v", err)
|
||||
}
|
||||
v, err := l.Get()
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
if v["v"] != "good" {
|
||||
t.Fatalf("expected last-good value, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_NotFoundReset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := write(t, dir, "x")
|
||||
l := New(p, parse(nil))
|
||||
if _, err := l.Get(); err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if err := os.Remove(p); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if _, err := l.Get(); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
// Восстановили файл — снова загружается.
|
||||
write(t, dir, "y")
|
||||
p2 := filepath.Join(dir, "cfg.json")
|
||||
if v, err := l.Get(); err != nil || v["v"] != "y" {
|
||||
t.Fatalf("after restore: %v %v", v, err)
|
||||
} else if p2 == "" {
|
||||
t.Fatal("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_Concurrent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := write(t, dir, "start")
|
||||
l := New(p, parse(nil))
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < 20; i++ {
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
_, _ = l.Get()
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
<-done
|
||||
}
|
||||
// После конкурентных чтений — кэш валиден.
|
||||
if v, err := l.Get(); err != nil || v["v"] != "start" {
|
||||
t.Fatalf("after concurrent: %v %v", v, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user