| # Configuration Management Refactoring Plan |
|
|
| ## Current State Analysis |
|
|
| **File**: `internal/config/config.go` (1,767 lines) |
|
|
| **Issues**: |
| - Single file with all config structures |
| - Mix of loading, validation, and migration logic |
| - Complex nested structs with 326+ lines of YAML example |
| - No clear separation between config sources (file, env, DB) |
| - Migration logic scattered throughout |
| - No structured validation (manual checks) |
|
|
| ## Target Architecture |
|
|
| ``` |
| internal/config/ |
| ├── domain/ |
| │ ├── models.go # Core config structs (no logic) |
| │ └── validation.go # Validation rules |
| ├── loader/ |
| │ ├── file.go # YAML/JSON file loading |
| │ ├── env.go # Environment variable loading |
| │ └── merge.go # Config merging logic |
| ├── provider/ |
| │ ├── interface.go # ConfigProvider interface |
| │ ├── file.go # File-based provider |
| │ ├── postgres.go # PostgreSQL provider |
| │ ├── git.go # Git-based provider |
| │ └── objectstore.go # S3/MinIO provider |
| ├── migrations/ |
| │ ├── registry.go # Migration registry |
| │ ├── v1_to_v2.go # Specific migrations |
| │ └── runner.go # Migration runner |
| ├── watcher/ |
| │ └── file.go # File watching for hot reload |
| └── config.go # Public API (facade) |
| ``` |
|
|
| ## Phase 1: Domain Models Extraction (Week 1) |
|
|
| ### 1.1 Core Models |
| **File**: `internal/config/domain/models.go` |
|
|
| ```go |
| package domain |
| |
| import ( |
| "time" |
| "github.com/echyai/cliproxyapi/internal/domain/auth" |
| ) |
| |
| // Config is the root configuration |
| type Config struct { |
| Version string `yaml:"version" json:"version" validate:"required,semver"` |
| Server Server `yaml:"server" json:"server" validate:"required"` |
| Providers Providers `yaml:"providers" json:"providers"` |
| Storage Storage `yaml:"storage" json:"storage" validate:"required"` |
| Logging Logging `yaml:"logging" json:"logging"` |
| Metrics Metrics `yaml:"metrics" json:"metrics"` |
| } |
| |
| func (c *Config) Validate() error { |
| validate := validator.New() |
| validate.RegisterValidation("semver", validateSemver) |
| return validate.Struct(c) |
| } |
| |
| // Server configuration |
| type Server struct { |
| Host string `yaml:"host" json:"host" validate:"hostname|ip"` |
| Port int `yaml:"port" json:"port" validate:"required,min=1,max=65535"` |
| ReadTimeout time.Duration `yaml:"read_timeout" json:"read_timeout" validate:"min=1s"` |
| WriteTimeout time.Duration `yaml:"write_timeout" json:"write_timeout" validate:"min=1s"` |
| MaxHeaderBytes int `yaml:"max_header_bytes" json:"max_header_bytes" validate:"min=1024"` |
| TLS *TLSConfig `yaml:"tls,omitempty" json:"tls,omitempty"` |
| } |
| |
| // TLS configuration |
| type TLSConfig struct { |
| CertFile string `yaml:"cert_file" json:"cert_file" validate:"required,file"` |
| KeyFile string `yaml:"key_file" json:"key_file" validate:"required,file"` |
| } |
| |
| // Providers configuration |
| type Providers struct { |
| Gemini *GeminiConfig `yaml:"gemini,omitempty" json:"gemini,omitempty"` |
| Claude *ClaudeConfig `yaml:"claude,omitempty" json:"claude,omitempty"` |
| Codex *CodexConfig `yaml:"codex,omitempty" json:"codex,omitempty"` |
| Antigravity *AntigravityConfig `yaml:"antigravity,omitempty" json:"antigravity,omitempty"` |
| Qwen *QwenConfig `yaml:"qwen,omitempty" json:"qwen,omitempty"` |
| Vertex *VertexConfig `yaml:"vertex,omitempty" json:"vertex,omitempty"` |
| } |
| |
| // Gemini configuration |
| type GeminiConfig struct { |
| Enabled bool `yaml:"enabled" json:"enabled"` |
| APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty" validate:"omitempty,min=10"` |
| OAuth *OAuthConfig `yaml:"oauth,omitempty" json:"oauth,omitempty"` |
| RateLimit RateLimitConfig `yaml:"rate_limit" json:"rate_limit"` |
| Models []ModelConfig `yaml:"models" json:"models" validate:"dive"` |
| } |
| |
| // OAuth configuration (shared) |
| type OAuthConfig struct { |
| ClientID string `yaml:"client_id" validate:"required"` |
| ClientSecret string `yaml:"client_secret" validate:"required"` |
| RedirectURL string `yaml:"redirect_url" validate:"required,url"` |
| Scopes []string `yaml:"scopes" validate:"min=1"` |
| } |
| |
| // Rate limit configuration |
| type RateLimitConfig struct { |
| RequestsPerSecond float64 `yaml:"requests_per_second" validate:"min=0.1"` |
| Burst int `yaml:"burst" validate:"min=1"` |
| Cooldown time.Duration `yaml:"cooldown" validate:"min=0"` |
| } |
| |
| // Model configuration |
| type ModelConfig struct { |
| Name string `yaml:"name" validate:"required"` |
| DisplayName string `yaml:"display_name"` |
| MaxTokens int `yaml:"max_tokens" validate:"min=1"` |
| Enabled bool `yaml:"enabled"` |
| } |
| |
| // Storage configuration |
| type Storage struct { |
| Type StorageType `yaml:"type" validate:"required,oneof=file postgres git objectstore"` |
| File *FileStorage `yaml:"file,omitempty"` |
| Postgres *PostgresStorage `yaml:"postgres,omitempty"` |
| Git *GitStorage `yaml:"git,omitempty"` |
| Object *ObjectStorage `yaml:"object,omitempty"` |
| } |
| |
| type StorageType string |
| |
| const ( |
| StorageTypeFile StorageType = "file" |
| StorageTypePostgres StorageType = "postgres" |
| StorageTypeGit StorageType = "git" |
| StorageTypeObject StorageType = "objectstore" |
| ) |
| |
| // FileStorage configuration |
| type FileStorage struct { |
| Path string `yaml:"path" validate:"required,dirpath"` |
| } |
| |
| // PostgresStorage configuration |
| type PostgresStorage struct { |
| Host string `yaml:"host" validate:"required"` |
| Port int `yaml:"port" validate:"min=1,max=65535"` |
| Database string `yaml:"database" validate:"required"` |
| Username string `yaml:"username" validate:"required"` |
| Password string `yaml:"password" validate:"required"` |
| SSLMode string `yaml:"ssl_mode" validate:"oneof=disable require verify-ca verify-full"` |
| } |
| |
| // Logging configuration |
| type Logging struct { |
| Level string `yaml:"level" validate:"oneof=debug info warn error fatal panic"` |
| Format string `yaml:"format" validate:"oneof=json text"` |
| Output string `yaml:"output" validate:"oneof=stdout stderr file"` |
| File string `yaml:"file,omitempty"` |
| } |
| |
| // Metrics configuration |
| type Metrics struct { |
| Enabled bool `yaml:"enabled"` |
| Path string `yaml:"path" validate:"startswith=/"` |
| Port int `yaml:"port" validate:"min=1,max=65535"` |
| } |
| |
| // Provider-specific configs (Claude, Codex, etc. follow similar pattern) |
| type ClaudeConfig struct { |
| Enabled bool `yaml:"enabled"` |
| APIKey string `yaml:"api_key,omitempty"` |
| OAuth *OAuthConfig `yaml:"oauth,omitempty"` |
| RateLimit RateLimitConfig `yaml:"rate_limit"` |
| } |
| |
| // ... other provider configs |
| ``` |
|
|
| ### 1.2 Validation Rules |
| **File**: `internal/config/domain/validation.go` |
|
|
| ```go |
| package domain |
| |
| import ( |
| "github.com/go-playground/validator/v10" |
| "regexp" |
| ) |
| |
| var semverRegex = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?(?:\+([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?$`) |
| |
| func validateSemver(fl validator.FieldLevel) bool { |
| return semverRegex.MatchString(fl.Field().String()) |
| } |
| |
| // ConfigValidator handles validation with custom rules |
| type ConfigValidator struct { |
| validate *validator.Validate |
| } |
| |
| func NewValidator() *ConfigValidator { |
| v := validator.New() |
| v.RegisterValidation("semver", validateSemver) |
| return &ConfigValidator{validate: v} |
| } |
| |
| func (cv *ConfigValidator) Validate(cfg *Config) error { |
| if err := cv.validate.Struct(cfg); err != nil { |
| return NewValidationError(err) |
| } |
| |
| // Cross-field validations |
| if err := cv.validateStorage(cfg.Storage); err != nil { |
| return err |
| } |
| |
| if err := cv.validateProviders(cfg.Providers); err != nil { |
| return err |
| } |
| |
| return nil |
| } |
| |
| func (cv *ConfigValidator) validateStorage(s Storage) error { |
| switch s.Type { |
| case StorageTypeFile: |
| if s.File == nil { |
| return ValidationError{Field: "storage.file", Message: "required when type is file"} |
| } |
| case StorageTypePostgres: |
| if s.Postgres == nil { |
| return ValidationError{Field: "storage.postgres", Message: "required when type is postgres"} |
| } |
| // ... other cases |
| } |
| return nil |
| } |
| |
| func (cv *ConfigValidator) validateProviders(p Providers) error { |
| // At least one provider should be enabled |
| hasEnabled := false |
| // Check each provider... |
| if !hasEnabled { |
| return ValidationError{Message: "at least one provider must be enabled"} |
| } |
| return nil |
| } |
| ``` |
|
|
| ## Phase 2: Provider Interface (Week 2) |
|
|
| ### 2.1 Provider Interface |
| **File**: `internal/config/provider/interface.go` |
|
|
| ```go |
| package provider |
| |
| import ( |
| "context" |
| "github.com/echyai/cliproxyapi/internal/config/domain" |
| ) |
| |
| // Provider loads and saves configuration |
| type Provider interface { |
| // Load retrieves the current configuration |
| Load(ctx context.Context) (*domain.Config, error) |
| |
| // Save persists the configuration |
| Save(ctx context.Context, cfg *domain.Config) error |
| |
| // Watch returns a channel that receives config updates |
| Watch(ctx context.Context) (<-chan domain.Config, error) |
| |
| // Close cleans up resources |
| Close() error |
| } |
| |
| // ProviderFactory creates providers based on type |
| type ProviderFactory struct { |
| // dependencies |
| } |
| |
| func (f *ProviderFactory) Create(storage domain.Storage) (Provider, error) { |
| switch storage.Type { |
| case domain.StorageTypeFile: |
| return NewFileProvider(storage.File) |
| case domain.StorageTypePostgres: |
| return NewPostgresProvider(storage.Postgres) |
| case domain.StorageTypeGit: |
| return NewGitProvider(storage.Git) |
| case domain.StorageTypeObject: |
| return NewObjectProvider(storage.Object) |
| default: |
| return nil, fmt.Errorf("unknown storage type: %s", storage.Type) |
| } |
| } |
| ``` |
|
|
| ### 2.2 File Provider |
| **File**: `internal/config/provider/file.go` |
|
|
| ```go |
| package provider |
| |
| import ( |
| "context" |
| "fmt" |
| "os" |
| "path/filepath" |
| |
| "github.com/fsnotify/fsnotify" |
| "gopkg.in/yaml.v3" |
| |
| "github.com/echyai/cliproxyapi/internal/config/domain" |
| ) |
| |
| type FileProvider struct { |
| path string |
| watcher *fsnotify.Watcher |
| validate *domain.ConfigValidator |
| } |
| |
| func NewFileProvider(cfg *domain.FileStorage) (*FileProvider, error) { |
| absPath, err := filepath.Abs(cfg.Path) |
| if err != nil { |
| return nil, fmt.Errorf("invalid path: %w", err) |
| } |
| |
| return &FileProvider{ |
| path: absPath, |
| validate: domain.NewValidator(), |
| }, nil |
| } |
| |
| func (p *FileProvider) Load(ctx context.Context) (*domain.Config, error) { |
| data, err := os.ReadFile(p.path) |
| if err != nil { |
| if os.IsNotExist(err) { |
| return nil, domain.ErrConfigNotFound |
| } |
| return nil, fmt.Errorf("read config: %w", err) |
| } |
| |
| var cfg domain.Config |
| if err := yaml.Unmarshal(data, &cfg); err != nil { |
| return nil, fmt.Errorf("parse config: %w", err) |
| } |
| |
| if err := p.validate.Validate(&cfg); err != nil { |
| return nil, fmt.Errorf("validate config: %w", err) |
| } |
| |
| return &cfg, nil |
| } |
| |
| func (p *FileProvider) Save(ctx context.Context, cfg *domain.Config) error { |
| if err := p.validate.Validate(cfg); err != nil { |
| return err |
| } |
| |
| data, err := yaml.Marshal(cfg) |
| if err != nil { |
| return fmt.Errorf("marshal config: %w", err) |
| } |
| |
| // Write atomically |
| tmpPath := p.path + ".tmp" |
| if err := os.WriteFile(tmpPath, data, 0644); err != nil { |
| return fmt.Errorf("write temp file: %w", err) |
| } |
| |
| return os.Rename(tmpPath, p.path) |
| } |
| |
| func (p *FileProvider) Watch(ctx context.Context) (<-chan domain.Config, error) { |
| watcher, err := fsnotify.NewWatcher() |
| if err != nil { |
| return nil, err |
| } |
| |
| if err := watcher.Add(p.path); err != nil { |
| return nil, err |
| } |
| |
| p.watcher = watcher |
| updates := make(chan domain.Config) |
| |
| go func() { |
| defer close(updates) |
| defer watcher.Close() |
| |
| for { |
| select { |
| case event, ok := <-watcher.Events: |
| if !ok { |
| return |
| } |
| if event.Op&fsnotify.Write == fsnotify.Write { |
| cfg, err := p.Load(ctx) |
| if err == nil { |
| updates <- *cfg |
| } |
| } |
| case err, ok := <-watcher.Errors: |
| if !ok { |
| return |
| } |
| // Log error |
| _ = err |
| case <-ctx.Done(): |
| return |
| } |
| } |
| }() |
| |
| return updates, nil |
| } |
| |
| func (p *FileProvider) Close() error { |
| if p.watcher != nil { |
| return p.watcher.Close() |
| } |
| return nil |
| } |
| ``` |
|
|
| ## Phase 3: Migration System (Week 2) |
|
|
| ### 3.1 Migration Framework |
| **File**: `internal/config/migrations/registry.go` |
|
|
| ```go |
| package migrations |
| |
| import ( |
| "fmt" |
| |
| "github.com/echyai/cliproxyapi/internal/config/domain" |
| ) |
| |
| // Migration transforms config from one version to another |
| type Migration interface { |
| FromVersion() string |
| ToVersion() string |
| Migrate(cfg map[string]interface{}) (map[string]interface{}, error) |
| } |
| |
| // Registry holds all migrations |
| type Registry struct { |
| migrations map[string]Migration |
| } |
| |
| func NewRegistry() *Registry { |
| r := &Registry{migrations: make(map[string]Migration)} |
| r.registerDefaults() |
| return r |
| } |
| |
| func (r *Registry) register(m Migration) { |
| key := fmt.Sprintf("%s->%s", m.FromVersion(), m.ToVersion()) |
| r.migrations[key] = m |
| } |
| |
| func (r *Registry) registerDefaults() { |
| r.register(&V1ToV2Migration{}) |
| r.register(&V2ToV3Migration{}) |
| // ... more migrations |
| } |
| |
| // Runner executes migrations |
| type Runner struct { |
| registry *Registry |
| } |
| |
| func (r *Runner) Migrate(cfg map[string]interface{}, targetVersion string) (map[string]interface{}, error) { |
| currentVersion, _ := cfg["version"].(string) |
| if currentVersion == "" { |
| currentVersion = "1.0.0" |
| } |
| |
| for currentVersion != targetVersion { |
| key := fmt.Sprintf("%s->%s", currentVersion, targetVersion) |
| migration, ok := r.registry.migrations[key] |
| if !ok { |
| // Try to find intermediate step |
| nextVersion := findNextVersion(currentVersion) |
| if nextVersion == "" { |
| return nil, fmt.Errorf("no migration path from %s to %s", currentVersion, targetVersion) |
| } |
| key = fmt.Sprintf("%s->%s", currentVersion, nextVersion) |
| migration = r.registry.migrations[key] |
| } |
| |
| var err error |
| cfg, err = migration.Migrate(cfg) |
| if err != nil { |
| return nil, fmt.Errorf("migrate %s: %w", key, err) |
| } |
| currentVersion = migration.ToVersion() |
| } |
| |
| return cfg, nil |
| } |
| ``` |
|
|
| ## Phase 4: Public API Facade (Week 3) |
|
|
| **File**: `internal/config/config.go` (simplified facade) |
|
|
| ```go |
| package config |
| |
| import ( |
| "context" |
| "sync" |
| |
| "github.com/echyai/cliproxyapi/internal/config/domain" |
| "github.com/echyai/cliproxyapi/internal/config/migrations" |
| "github.com/echyai/cliproxyapi/internal/config/provider" |
| ) |
| |
| // Manager is the public API for configuration |
| type Manager struct { |
| provider provider.Provider |
| current *domain.Config |
| mu sync.RWMutex |
| validate *domain.ConfigValidator |
| } |
| |
| func NewManager(storage domain.Storage) (*Manager, error) { |
| p, err := provider.NewFactory().Create(storage) |
| if err != nil { |
| return nil, err |
| } |
| |
| return &Manager{ |
| provider: p, |
| validate: domain.NewValidator(), |
| }, nil |
| } |
| |
| func (m *Manager) Load(ctx context.Context) error { |
| cfg, err := m.provider.Load(ctx) |
| if err != nil { |
| return err |
| } |
| |
| m.mu.Lock() |
| m.current = cfg |
| m.mu.Unlock() |
| |
| return nil |
| } |
| |
| func (m *Manager) Get() *domain.Config { |
| m.mu.RLock() |
| defer m.mu.RUnlock() |
| return m.current |
| } |
| |
| func (m *Manager) Update(ctx context.Context, updater func(*domain.Config) error) error { |
| m.mu.Lock() |
| defer m.mu.Unlock() |
| |
| cfg := *m.current // Copy |
| if err := updater(&cfg); err != nil { |
| return err |
| } |
| |
| if err := m.validate.Validate(&cfg); err != nil { |
| return err |
| } |
| |
| if err := m.provider.Save(ctx, &cfg); err != nil { |
| return err |
| } |
| |
| m.current = &cfg |
| return nil |
| } |
| |
| func (m *Manager) Watch(ctx context.Context) (<-chan domain.Config, error) { |
| return m.provider.Watch(ctx) |
| } |
| |
| func (m *Manager) Close() error { |
| return m.provider.Close() |
| } |
| ``` |
|
|
| ## Migration Steps |
|
|
| 1. **Week 1**: Create new structure alongside existing code |
| 2. **Week 2**: Implement providers and migrations |
| 3. **Week 3**: Switch to new config system, keep old as backup |
| 4. **Week 4**: Remove old config code after testing |
|
|
| ## Success Metrics |
|
|
| - [ ] Config file split into logical packages |
| - [ ] Validation using struct tags |
| - [ ] All providers implement same interface |
| - [ ] Hot reload works for all storage types |
| - [ ] Migrations tested and documented |
|
|