Merge pull request 'feat: add versioned config migrations' (#12) from issue-5-versioned-config into main
Reviewed-on: https://gitea.lclr.dev/AI/mcp-framework/pulls/12
This commit is contained in:
commit
3437d265d4
3 changed files with 384 additions and 7 deletions
24
README.md
24
README.md
|
|
@ -112,6 +112,30 @@ Notes :
|
|||
- le répertoire parent est forcé en `0700`
|
||||
- l'écriture est atomique via un fichier temporaire puis `rename`
|
||||
- si le fichier n'existe pas, `Load` et `LoadDefault` retournent une config vide par défaut
|
||||
- `NewStoreWithOptions` permet de définir une version cible, des migrations JSON (`from -> to`) et une validation explicite après chargement
|
||||
- une config plus récente que la version supportée, ou sans chemin de migration complet, retourne une erreur explicite
|
||||
|
||||
Exemple de store versionné :
|
||||
|
||||
```go
|
||||
store := config.NewStoreWithOptions[Profile]("my-mcp", config.Options[Profile]{
|
||||
Version: 2,
|
||||
Migrations: map[int]config.Migration{
|
||||
1: func(doc map[string]json.RawMessage) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Validator: func(cfg config.FileConfig[Profile]) []config.ValidationIssue {
|
||||
if cfg.CurrentProfile == "" {
|
||||
return []config.ValidationIssue{{
|
||||
Path: "current_profile",
|
||||
Message: "must not be empty",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Secrets
|
||||
|
||||
|
|
|
|||
198
config/config.go
198
config/config.go
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -13,31 +14,94 @@ const (
|
|||
DefaultFile = "config.json"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFutureVersion = errors.New("future config version")
|
||||
ErrUnsupportedVersion = errors.New("unsupported config version")
|
||||
ErrInvalidConfig = errors.New("invalid config")
|
||||
)
|
||||
|
||||
type FileConfig[T any] struct {
|
||||
Version int `json:"version"`
|
||||
CurrentProfile string `json:"current_profile"`
|
||||
Profiles map[string]T `json:"profiles"`
|
||||
}
|
||||
|
||||
type ValidationIssue struct {
|
||||
Path string
|
||||
Message string
|
||||
}
|
||||
|
||||
type ValidationError struct {
|
||||
Issues []ValidationIssue
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, 0, len(e.Issues))
|
||||
for _, issue := range e.Issues {
|
||||
if issue.Path == "" {
|
||||
parts = append(parts, issue.Message)
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s: %s", issue.Path, issue.Message))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("invalid config: %s", strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
func (e *ValidationError) Unwrap() error {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
|
||||
type Migration func(doc map[string]json.RawMessage) error
|
||||
|
||||
type Validator[T any] func(FileConfig[T]) []ValidationIssue
|
||||
|
||||
type Options[T any] struct {
|
||||
FileName string
|
||||
Version int
|
||||
Migrations map[int]Migration
|
||||
Validator Validator[T]
|
||||
}
|
||||
|
||||
type Store[T any] struct {
|
||||
dirName string
|
||||
fileName string
|
||||
dirName string
|
||||
fileName string
|
||||
version int
|
||||
migrations map[int]Migration
|
||||
validator Validator[T]
|
||||
}
|
||||
|
||||
func NewStore[T any](dirName string) Store[T] {
|
||||
return NewStoreWithFile[T](dirName, DefaultFile)
|
||||
return NewStoreWithOptions[T](dirName, Options[T]{})
|
||||
}
|
||||
|
||||
func NewStoreWithFile[T any](dirName, fileName string) Store[T] {
|
||||
return NewStoreWithOptions[T](dirName, Options[T]{FileName: fileName})
|
||||
}
|
||||
|
||||
func NewStoreWithOptions[T any](dirName string, options Options[T]) Store[T] {
|
||||
fileName := options.FileName
|
||||
if fileName == "" {
|
||||
fileName = DefaultFile
|
||||
}
|
||||
|
||||
version := options.Version
|
||||
if version <= 0 {
|
||||
version = CurrentVersion
|
||||
}
|
||||
|
||||
return Store[T]{
|
||||
dirName: dirName,
|
||||
fileName: fileName,
|
||||
dirName: dirName,
|
||||
fileName: fileName,
|
||||
version: version,
|
||||
migrations: buildMigrations(version, options.Migrations),
|
||||
validator: options.Validator,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Store[T]) Default() FileConfig[T] {
|
||||
return FileConfig[T]{
|
||||
Version: CurrentVersion,
|
||||
Version: s.version,
|
||||
Profiles: map[string]T{},
|
||||
}
|
||||
}
|
||||
|
|
@ -64,12 +128,29 @@ func (s Store[T]) Load(path string) (FileConfig[T], error) {
|
|||
return s.Default(), nil
|
||||
}
|
||||
|
||||
doc, err := parseDocument(path, data)
|
||||
if err != nil {
|
||||
return FileConfig[T]{}, err
|
||||
}
|
||||
|
||||
if err := s.migrateDocument(path, doc); err != nil {
|
||||
return FileConfig[T]{}, err
|
||||
}
|
||||
|
||||
cfg := s.Default()
|
||||
data, err = json.Marshal(doc)
|
||||
if err != nil {
|
||||
return FileConfig[T]{}, fmt.Errorf("encode config %s: %w", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig[T]{}, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
|
||||
s.normalize(&cfg)
|
||||
if err := s.validate(cfg); err != nil {
|
||||
return FileConfig[T]{}, fmt.Errorf("validate config %s: %w", path, err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +170,9 @@ func (s Store[T]) LoadDefault() (FileConfig[T], string, error) {
|
|||
|
||||
func (s Store[T]) Save(path string, cfg FileConfig[T]) error {
|
||||
s.normalize(&cfg)
|
||||
if err := s.validate(cfg); err != nil {
|
||||
return fmt.Errorf("validate config %s: %w", path, err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
|
|
@ -153,9 +237,109 @@ func (s Store[T]) SaveDefault(cfg FileConfig[T]) (string, error) {
|
|||
|
||||
func (s Store[T]) normalize(cfg *FileConfig[T]) {
|
||||
if cfg.Version == 0 {
|
||||
cfg.Version = CurrentVersion
|
||||
cfg.Version = s.version
|
||||
}
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = map[string]T{}
|
||||
}
|
||||
}
|
||||
|
||||
func (s Store[T]) validate(cfg FileConfig[T]) error {
|
||||
issues := make([]ValidationIssue, 0, 2)
|
||||
if cfg.Version != s.version {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: "version",
|
||||
Message: fmt.Sprintf("must be %d", s.version),
|
||||
})
|
||||
}
|
||||
if cfg.CurrentProfile != "" {
|
||||
if _, ok := cfg.Profiles[cfg.CurrentProfile]; !ok {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: "current_profile",
|
||||
Message: fmt.Sprintf("references unknown profile %q", cfg.CurrentProfile),
|
||||
})
|
||||
}
|
||||
}
|
||||
if s.validator != nil {
|
||||
issues = append(issues, s.validator(cfg)...)
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ValidationError{Issues: issues}
|
||||
}
|
||||
|
||||
func (s Store[T]) migrateDocument(path string, doc map[string]json.RawMessage) error {
|
||||
version, err := documentVersion(doc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
if version > s.version {
|
||||
return fmt.Errorf("%w: config version %d is newer than supported version %d", ErrFutureVersion, version, s.version)
|
||||
}
|
||||
|
||||
for version < s.version {
|
||||
migration, ok := s.migrations[version]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: no migration registered from version %d to %d", ErrUnsupportedVersion, version, version+1)
|
||||
}
|
||||
if err := migration(doc); err != nil {
|
||||
return fmt.Errorf("migrate config %s from version %d to %d: %w", path, version, version+1, err)
|
||||
}
|
||||
version++
|
||||
if err := setDocumentVersion(doc, version); err != nil {
|
||||
return fmt.Errorf("set config version %s to %d: %w", path, version, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMigrations(version int, custom map[int]Migration) map[int]Migration {
|
||||
migrations := map[int]Migration{}
|
||||
if version >= 1 {
|
||||
migrations[0] = func(doc map[string]json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for fromVersion, migration := range custom {
|
||||
migrations[fromVersion] = migration
|
||||
}
|
||||
|
||||
return migrations
|
||||
}
|
||||
|
||||
func parseDocument(path string, data []byte) (map[string]json.RawMessage, error) {
|
||||
var doc map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func documentVersion(doc map[string]json.RawMessage) (int, error) {
|
||||
rawVersion, ok := doc["version"]
|
||||
if !ok || len(rawVersion) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var version int
|
||||
if err := json.Unmarshal(rawVersion, &version); err != nil {
|
||||
return 0, fmt.Errorf("decode version: %w", err)
|
||||
}
|
||||
if version < 0 {
|
||||
return 0, fmt.Errorf("%w: version must be non-negative", ErrUnsupportedVersion)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func setDocumentVersion(doc map[string]json.RawMessage, version int) error {
|
||||
rawVersion, err := json.Marshal(version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
doc["version"] = rawVersion
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -28,6 +31,40 @@ func TestLoadMissingReturnsDefault(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadMigratesLegacyConfigAndPreservesProfiles(t *testing.T) {
|
||||
store := NewStore[testProfile]("mcp-framework-test")
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
data := []byte(`{
|
||||
"current_profile": "prod",
|
||||
"profiles": {
|
||||
"prod": {
|
||||
"base_url": "https://api.example.com",
|
||||
"stream_id": "stream-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := store.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Version != CurrentVersion {
|
||||
t.Fatalf("Version = %d, want %d", cfg.Version, CurrentVersion)
|
||||
}
|
||||
if cfg.CurrentProfile != "prod" {
|
||||
t.Fatalf("CurrentProfile = %q, want prod", cfg.CurrentProfile)
|
||||
}
|
||||
if cfg.Profiles["prod"].StreamID != "stream-1" {
|
||||
t.Fatalf("StreamID = %q, want stream-1", cfg.Profiles["prod"].StreamID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
store := NewStore[testProfile]("mcp-framework-test")
|
||||
dir := t.TempDir()
|
||||
|
|
@ -76,3 +113,135 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
|
|||
t.Fatalf("BaseURL = %q", cfg.Profiles["prod"].BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesRegisteredMigrations(t *testing.T) {
|
||||
store := NewStoreWithOptions[testProfile]("mcp-framework-test", Options[testProfile]{
|
||||
Version: 2,
|
||||
Migrations: map[int]Migration{
|
||||
1: func(doc map[string]json.RawMessage) error {
|
||||
doc["migrated"] = json.RawMessage(`true`)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
data := []byte(`{
|
||||
"version": 1,
|
||||
"current_profile": "prod",
|
||||
"profiles": {
|
||||
"prod": {
|
||||
"base_url": "https://api.example.com",
|
||||
"stream_id": "stream-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := store.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Version != 2 {
|
||||
t.Fatalf("Version = %d, want 2", cfg.Version)
|
||||
}
|
||||
if cfg.CurrentProfile != "prod" {
|
||||
t.Fatalf("CurrentProfile = %q, want prod", cfg.CurrentProfile)
|
||||
}
|
||||
if cfg.Profiles["prod"].BaseURL != "https://api.example.com" {
|
||||
t.Fatalf("BaseURL = %q, want https://api.example.com", cfg.Profiles["prod"].BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsFutureVersion(t *testing.T) {
|
||||
store := NewStore[testProfile]("mcp-framework-test")
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
data := []byte(`{
|
||||
"version": 2,
|
||||
"profiles": {}
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err := store.Load(path)
|
||||
if !errors.Is(err, ErrFutureVersion) {
|
||||
t.Fatalf("Load error = %v, want ErrFutureVersion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingMigrationPath(t *testing.T) {
|
||||
store := NewStoreWithOptions[testProfile]("mcp-framework-test", Options[testProfile]{Version: 2})
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
data := []byte(`{
|
||||
"version": 1,
|
||||
"profiles": {}
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err := store.Load(path)
|
||||
if !errors.Is(err, ErrUnsupportedVersion) {
|
||||
t.Fatalf("Load error = %v, want ErrUnsupportedVersion", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no migration registered from version 1 to 2") {
|
||||
t.Fatalf("Load error = %v, want missing migration detail", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadValidatesConfig(t *testing.T) {
|
||||
store := NewStoreWithOptions[testProfile]("mcp-framework-test", Options[testProfile]{
|
||||
Validator: func(cfg FileConfig[testProfile]) []ValidationIssue {
|
||||
issues := make([]ValidationIssue, 0, len(cfg.Profiles))
|
||||
for name, profile := range cfg.Profiles {
|
||||
if profile.BaseURL == "" {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: "profiles." + name + ".base_url",
|
||||
Message: "must not be empty",
|
||||
})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
},
|
||||
})
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
data := []byte(`{
|
||||
"version": 1,
|
||||
"current_profile": "prod",
|
||||
"profiles": {
|
||||
"prod": {
|
||||
"stream_id": "stream-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err := store.Load(path)
|
||||
if !errors.Is(err, ErrInvalidConfig) {
|
||||
t.Fatalf("Load error = %v, want ErrInvalidConfig", err)
|
||||
}
|
||||
|
||||
var validationErr *ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("Load error = %v, want ValidationError", err)
|
||||
}
|
||||
if len(validationErr.Issues) != 1 {
|
||||
t.Fatalf("Validation issues = %v, want 1 issue", validationErr.Issues)
|
||||
}
|
||||
if validationErr.Issues[0].Path != "profiles.prod.base_url" {
|
||||
t.Fatalf("Validation issue path = %q, want profiles.prod.base_url", validationErr.Issues[0].Path)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue