feat: add versioned config migrations

This commit is contained in:
thibaud-lclr 2026-04-13 17:28:47 +02:00
parent ad463d971e
commit c90c22e1de
3 changed files with 384 additions and 7 deletions

View file

@ -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

View file

@ -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
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,
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
}

View file

@ -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)
}
}