From c90c22e1de63912484f43d04d97c8d38645b921d Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 13 Apr 2026 17:28:47 +0200 Subject: [PATCH] feat: add versioned config migrations --- README.md | 24 +++++ config/config.go | 198 ++++++++++++++++++++++++++++++++++++++++-- config/config_test.go | 169 +++++++++++++++++++++++++++++++++++ 3 files changed, 384 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9554e19..4e416e2 100644 --- a/README.md +++ b/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 diff --git a/config/config.go b/config/config.go index 2603d5e..23bdab8 100644 --- a/config/config.go +++ b/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 +} diff --git a/config/config_test.go b/config/config_test.go index 47952c5..8ed9392 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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) + } +}