Добавил конвенции для конфигурации и сделал рефакторинг кода
This commit is contained in:
@@ -5,11 +5,16 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
// DefaultPath — имя конфига по умолчанию: ищется в рабочей директории
|
||||
// процесса. Переопределяется опцией --config=path.
|
||||
const DefaultPath = "config.toml"
|
||||
|
||||
// Config — корневая конфигурация сервиса (см. config.example.toml).
|
||||
type Config struct {
|
||||
QBittorrent QBittorrent `toml:"qbittorrent"`
|
||||
@@ -195,7 +200,15 @@ func Load(path string) (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// validate — fail-fast проверка конфига на старте: обязательные поля заданы,
|
||||
// медиа-пути доступны и не выходят из песочницы, диапазоны соблюдены, секреты
|
||||
// включённых секций не пусты. Длительности уже провалидированы при разборе
|
||||
// TOML (UnmarshalText). Лог об ошибке пишет граница (cmd/jellybit), не загрузчик.
|
||||
func (c *Config) validate() error {
|
||||
// Обязательные поля ядра.
|
||||
if c.QBittorrent.URL == "" {
|
||||
return errors.New("qbittorrent.url is empty")
|
||||
}
|
||||
if c.HTTP.Listen == "" {
|
||||
return errors.New("http.listen is empty")
|
||||
}
|
||||
@@ -205,5 +218,76 @@ func (c *Config) validate() error {
|
||||
if c.LLM.Type != "openai-compat" {
|
||||
return fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type)
|
||||
}
|
||||
|
||||
// Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги.
|
||||
for _, p := range []struct{ name, path string }{
|
||||
{"paths.downloads", c.Paths.Downloads},
|
||||
{"paths.movies", c.Paths.Movies},
|
||||
{"paths.series", c.Paths.Series},
|
||||
} {
|
||||
if err := validateMediaDir(p.name, p.path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Диапазоны.
|
||||
if t := c.Recognition.AutoConfidenceThreshold; t < 0 || t > 1 {
|
||||
return fmt.Errorf("recognition.auto_confidence_threshold %.3f is out of range [0, 1]", t)
|
||||
}
|
||||
if c.LLM.MaxRetries < 0 {
|
||||
return fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries)
|
||||
}
|
||||
|
||||
// Обязательные секреты включённых секций (ловит криво отрендеренный деплоем
|
||||
// файл). qBittorrent — ядро, пароль нужен всегда.
|
||||
if c.QBittorrent.Password == "" {
|
||||
return errors.New("qbittorrent.password is empty (required secret)")
|
||||
}
|
||||
// llm.api_key намеренно не обязателен: keyless-local LLM (LM Studio с
|
||||
// заданным base_url, но без ключа) — валидный документированный дефолт.
|
||||
|
||||
// Консистентность опциональных секций: enabled ⇒ заданы нужные поля/секреты.
|
||||
if c.Metadata.TMDB.Enabled && c.Metadata.TMDB.APIKey == "" {
|
||||
return errors.New("metadata.tmdb.enabled but metadata.tmdb.api_key is empty")
|
||||
}
|
||||
if c.Metadata.TVDB.Enabled && c.Metadata.TVDB.APIKey == "" {
|
||||
return errors.New("metadata.tvdb.enabled but metadata.tvdb.api_key is empty")
|
||||
}
|
||||
if c.Jellyfin.Enabled {
|
||||
if c.Jellyfin.URL == "" {
|
||||
return errors.New("jellyfin.enabled but jellyfin.url is empty")
|
||||
}
|
||||
if c.Jellyfin.APIKey == "" {
|
||||
return errors.New("jellyfin.enabled but jellyfin.api_key is empty (required secret)")
|
||||
}
|
||||
}
|
||||
if c.Telegram.Enabled && c.Telegram.Token == "" {
|
||||
return errors.New("telegram.enabled but telegram.token is empty (required secret)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMediaDir проверяет путь медиа-песочницы: непустой, абсолютный, без
|
||||
// traversal (filepath.Clean — без `..`/лишних разделителей) и указывает на
|
||||
// существующий доступный каталог. Отдельного корня песочницы в конфиге нет,
|
||||
// поэтому «строго под песочницей» обеспечиваем абсолютностью и отсутствием
|
||||
// traversal; единый монтируемый корень (/srv/media) — забота деплоя.
|
||||
func validateMediaDir(name, path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("%s is empty", name)
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
return fmt.Errorf("%s %q must be an absolute path", name, path)
|
||||
}
|
||||
if filepath.Clean(path) != path {
|
||||
return fmt.Errorf("%s %q must be a clean path (no .. or redundant separators)", name, path)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %q is not accessible: %w", name, path, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s %q is not a directory", name, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// validCfg возвращает минимально валидный конфиг поверх Default() с медиа-путями
|
||||
// во временном каталоге (существуют как директории).
|
||||
func validCfg(t *testing.T) *Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
c := Default()
|
||||
c.QBittorrent.Password = "secret"
|
||||
c.Paths.Downloads = filepath.Join(dir, "downloads")
|
||||
c.Paths.Movies = filepath.Join(dir, "movies")
|
||||
c.Paths.Series = filepath.Join(dir, "series")
|
||||
for _, p := range []string{c.Paths.Downloads, c.Paths.Movies, c.Paths.Series} {
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
// LLM по умолчанию без base_url — секция выключена, api_key не требуется.
|
||||
c.LLM.BaseURL = ""
|
||||
return c
|
||||
}
|
||||
|
||||
func TestValidate_OK(t *testing.T) {
|
||||
if err := validCfg(t).validate(); err != nil {
|
||||
t.Fatalf("ожидался валидный конфиг, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_KeylessLocalLLM — keyless-local LLM (задан base_url, пустой
|
||||
// api_key, напр. LM Studio) — валиден: ключ не обязателен.
|
||||
func TestValidate_KeylessLocalLLM(t *testing.T) {
|
||||
c := validCfg(t)
|
||||
c.LLM.BaseURL = "http://host.docker.internal:1234/v1"
|
||||
c.LLM.APIKey = ""
|
||||
if err := c.validate(); err != nil {
|
||||
t.Fatalf("keyless-local LLM должен быть валиден, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
want string
|
||||
}{
|
||||
{"empty qbittorrent.url", func(c *Config) { c.QBittorrent.URL = "" }, "qbittorrent.url"},
|
||||
{"empty qbittorrent.password", func(c *Config) { c.QBittorrent.Password = "" }, "qbittorrent.password"},
|
||||
{"empty db_path", func(c *Config) { c.Storage.DBPath = "" }, "storage.db_path"},
|
||||
{"bad llm.type", func(c *Config) { c.LLM.Type = "anthropic" }, "llm.type"},
|
||||
{"relative movies", func(c *Config) { c.Paths.Movies = "movies" }, "absolute"},
|
||||
{"traversal series", func(c *Config) { c.Paths.Series = c.Paths.Series + "/../x" }, "clean"},
|
||||
{"missing downloads", func(c *Config) { c.Paths.Downloads = "/no/such/dir/jellybit" }, "not accessible"},
|
||||
{"threshold high", func(c *Config) { c.Recognition.AutoConfidenceThreshold = 1.5 }, "auto_confidence_threshold"},
|
||||
{"negative retries", func(c *Config) { c.LLM.MaxRetries = -1 }, "max_retries"},
|
||||
{"tmdb enabled no key", func(c *Config) { c.Metadata.TMDB.Enabled = true }, "metadata.tmdb"},
|
||||
{"tvdb enabled no key", func(c *Config) { c.Metadata.TVDB.Enabled = true }, "metadata.tvdb"},
|
||||
{"jellyfin enabled no url", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "" }, "jellyfin.url"},
|
||||
{"jellyfin enabled no key", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "http://j"; c.Jellyfin.APIKey = "" }, "jellyfin.api_key"},
|
||||
{"telegram enabled no token", func(c *Config) { c.Telegram.Enabled = true }, "telegram.token"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := validCfg(t)
|
||||
tc.mutate(c)
|
||||
err := c.validate()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("ожидалась ошибка про %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user