// Package config загружает конфигурацию healthlog из TOML-файла. package config import ( "errors" "fmt" "os" "strings" "time" "github.com/pelletier/go-toml/v2" ) // DefaultPath — имя конфига по умолчанию: ищется в рабочей директории // процесса. Переопределяется опцией --config=path. const DefaultPath = "config.toml" // Config — корневая конфигурация сервиса (см. config.example.toml). type Config struct { Server Server `toml:"server"` Auth Auth `toml:"auth"` Storage Storage `toml:"storage"` Ingest Ingest `toml:"ingest"` Log Log `toml:"log"` } // Server — параметры HTTP-сервера. type Server struct { Addr string `toml:"addr"` // ReadTimeout — на всё чтение запроса вместе с телом. Держим щедрым: // экспорт истории — это десятки мегабайт по мобильной сети. ReadTimeout Duration `toml:"read_timeout"` WriteTimeout Duration `toml:"write_timeout"` } // Auth — токены доступа. Пустой список = проверка выключена (локальный // запуск в доверенной сети); сервис предупреждает об этом на старте. type Auth struct { WriteTokens []string `toml:"write_tokens"` ReadTokens []string `toml:"read_tokens"` } // Storage — где лежат сырой архив и витрина. type Storage struct { DBPath string `toml:"db_path"` ArchiveDir string `toml:"archive_dir"` } // Ingest — параметры приёма. type Ingest struct { MaxBodyMB int `toml:"max_body_mb"` } // Log — уровень и формат логов. type Log struct { Level string `toml:"level"` Format string `toml:"format"` } // Load читает TOML-конфиг, подставляет умолчания и валидирует результат. // Отсутствующий файл — не ошибка: сервис поднимается на умолчаниях, что // нужно для локального запуска. func Load(path string) (*Config, error) { var cfg Config data, err := os.ReadFile(path) switch { case errors.Is(err, os.ErrNotExist): // Конфига нет — работаем на умолчаниях. case err != nil: return nil, fmt.Errorf("read config: %w", err) default: if err := toml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } } cfg.applyDefaults() if err := cfg.validate(); err != nil { return nil, err } return &cfg, nil } func (c *Config) applyDefaults() { if c.Server.Addr == "" { c.Server.Addr = ":8080" } if c.Server.ReadTimeout == 0 { c.Server.ReadTimeout = Duration(5 * time.Minute) } if c.Server.WriteTimeout == 0 { c.Server.WriteTimeout = Duration(30 * time.Second) } if c.Storage.DBPath == "" { c.Storage.DBPath = "./healthlog.db" } if c.Storage.ArchiveDir == "" { c.Storage.ArchiveDir = "./raw" } if c.Ingest.MaxBodyMB == 0 { c.Ingest.MaxBodyMB = 64 } if c.Log.Level == "" { c.Log.Level = "info" } if c.Log.Format == "" { c.Log.Format = "json" } } // validate собирает все проблемы разом (errors.Join), чтобы не чинить конфиг // по одной строчке за перезапуск. func (c *Config) validate() error { var errs []error if c.Ingest.MaxBodyMB <= 0 { errs = append(errs, fmt.Errorf("ingest.max_body_mb: %w: %d", ErrOutOfRange, c.Ingest.MaxBodyMB)) } if c.Server.ReadTimeout <= 0 { errs = append(errs, fmt.Errorf("server.read_timeout: %w", ErrOutOfRange)) } if c.Server.WriteTimeout <= 0 { errs = append(errs, fmt.Errorf("server.write_timeout: %w", ErrOutOfRange)) } if !oneOf(c.Log.Level, "debug", "info", "warn", "error") { errs = append(errs, fmt.Errorf("log.level: %w: %q", ErrUnknownValue, c.Log.Level)) } if !oneOf(c.Log.Format, "json", "text") { errs = append(errs, fmt.Errorf("log.format: %w: %q", ErrUnknownValue, c.Log.Format)) } for i, t := range c.Auth.WriteTokens { if strings.TrimSpace(t) == "" { errs = append(errs, fmt.Errorf("auth.write_tokens[%d]: %w", i, ErrEmptyToken)) } } for i, t := range c.Auth.ReadTokens { if strings.TrimSpace(t) == "" { errs = append(errs, fmt.Errorf("auth.read_tokens[%d]: %w", i, ErrEmptyToken)) } } return errors.Join(errs...) } // Ошибки валидации конфига. var ( ErrOutOfRange = errors.New("значение вне допустимого диапазона") ErrUnknownValue = errors.New("недопустимое значение") ErrEmptyToken = errors.New("пустой токен") ) func oneOf(v string, allowed ...string) bool { for _, a := range allowed { if strings.EqualFold(v, a) { return true } } return false } // Duration — time.Duration, читаемая из TOML строкой Go-формата ("30s", "5m"). type Duration time.Duration // UnmarshalText разбирает Go-длительность из TOML-строки. func (d *Duration) UnmarshalText(text []byte) error { v, err := time.ParseDuration(string(text)) if err != nil { return fmt.Errorf("parse duration %q: %w", text, err) } *d = Duration(v) return nil } // D возвращает значение как time.Duration. func (d Duration) D() time.Duration { return time.Duration(d) }