From 84ffe0733e06860dfef0e90cd220804a9112e2d6 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 28 Jun 2026 20:53:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=BD=D1=86=D0=B8=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D1=81=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D0=BB=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 7 +- Dockerfile | 5 +- cmd/jellybit/healthcheck.go | 2 +- cmd/jellybit/healthcheck_test.go | 17 ++++- cmd/jellybit/recognize.go | 2 +- cmd/jellybit/serve.go | 2 +- config.example.toml | 98 ++++++++++++------------ docs/conventions/config.md | 123 +++++++++++++++++++++++++++++++ internal/config/config.go | 84 +++++++++++++++++++++ internal/config/config_test.go | 78 ++++++++++++++++++++ openspec/config.yaml | 8 +- 11 files changed, 371 insertions(+), 55 deletions(-) create mode 100644 docs/conventions/config.md create mode 100644 internal/config/config_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 0f15bb3..d6b25d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,9 +115,14 @@ Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`. - Раскладка: `cmd/jellybit` (точка входа) + `internal/<пакет>` по компонентам из [architecture.md](docs/specs/architecture.md). -- Ошибки оборачиваем с контекстом (`fmt.Errorf("...: %w", err)`). +- Ошибки — stdlib, обёртка с контекстом (`fmt.Errorf("...: %w", err)`), + проверка через `errors.Is`/`errors.As`, трансляция на внешней границе: + [docs/conventions/errors.md](docs/conventions/errors.md). - Логирование только через `slog`, без `fmt.Println` — уровни, обязательные поля и что не логировать см. [docs/conventions/logging.md](docs/conventions/logging.md). +- Конфигурация — только TOML; секреты рендерит деплой (Ansible+Vault) в + файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте: + [docs/conventions/config.md](docs/conventions/config.md). - Время — всегда с явным TZ (сервер в `Europe/Moscow`). Кросс-каттинг конвенции (как пишем код, а не что система делает) живут в diff --git a/Dockerfile b/Dockerfile index 366e070..7fa9340 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,9 @@ COPY jellybit /usr/local/bin/jellybit EXPOSE 8080 # В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из -# /config/config.toml — дефолтный путь). compose может переопределить параметры. +# конфига). Путь задаём явно: дефолт загрузчика — config.toml в рабочей +# директории, а конфиг смонтирован в /config. compose может переопределить. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD ["/usr/local/bin/jellybit", "healthcheck"] + CMD ["/usr/local/bin/jellybit", "healthcheck", "--config", "/config/config.toml"] ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/config/config.toml"] diff --git a/cmd/jellybit/healthcheck.go b/cmd/jellybit/healthcheck.go index a332a37..d54449a 100644 --- a/cmd/jellybit/healthcheck.go +++ b/cmd/jellybit/healthcheck.go @@ -16,7 +16,7 @@ import ( // нет shell/curl: docker зовёт сам бинарь. func runHealthcheck(args []string) error { fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError) - configPath := fs.String("config", "/config/config.toml", "путь к config.toml") + configPath := fs.String("config", config.DefaultPath, "путь к config.toml") if err := fs.Parse(args); err != nil { return err } diff --git a/cmd/jellybit/healthcheck_test.go b/cmd/jellybit/healthcheck_test.go index 848a315..36ba5ea 100644 --- a/cmd/jellybit/healthcheck_test.go +++ b/cmd/jellybit/healthcheck_test.go @@ -29,8 +29,21 @@ func serveHealthz(t *testing.T, status int) int { func writeConfig(t *testing.T, port int) string { t.Helper() - path := filepath.Join(t.TempDir(), "config.toml") - content := "[http]\nlisten = \"127.0.0.1:" + strconv.Itoa(port) + "\"\n" + dir := t.TempDir() + // Медиа-пути должны существовать как каталоги (fail-fast валидация конфига). + for _, sub := range []string{"downloads", "movies", "series"} { + if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(dir, "config.toml") + content := "" + + "[qbittorrent]\nurl = \"http://qbit:8080\"\npassword = \"secret\"\n\n" + + "[paths]\n" + + "downloads = \"" + filepath.Join(dir, "downloads") + "\"\n" + + "movies = \"" + filepath.Join(dir, "movies") + "\"\n" + + "series = \"" + filepath.Join(dir, "series") + "\"\n\n" + + "[http]\nlisten = \"127.0.0.1:" + strconv.Itoa(port) + "\"\n" if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } diff --git a/cmd/jellybit/recognize.go b/cmd/jellybit/recognize.go index edeb2f1..0f7a48c 100644 --- a/cmd/jellybit/recognize.go +++ b/cmd/jellybit/recognize.go @@ -23,7 +23,7 @@ import ( // Только чтение: ни записи в БД, ни хардлинков. func runRecognize(args []string) error { fs := flag.NewFlagSet("recognize", flag.ContinueOnError) - configPath := fs.String("config", "/config/config.toml", "путь к config.toml") + configPath := fs.String("config", config.DefaultPath, "путь к config.toml") dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)") contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания") if err := fs.Parse(args); err != nil { diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 804d1f6..7f6e062 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -34,7 +34,7 @@ import ( // воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM. func runServe(args []string) error { fs := flag.NewFlagSet("serve", flag.ContinueOnError) - configPath := fs.String("config", "/config/config.toml", "путь к config.toml") + configPath := fs.String("config", config.DefaultPath, "путь к config.toml") if err := fs.Parse(args); err != nil { return err } diff --git a/config.example.toml b/config.example.toml index 5c14c07..c8bc3af 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,76 +1,82 @@ -# Пример конфигурации jellybit. Реальный config.toml не коммитится (содержит -# секреты). Для локального запуска: db_path -> ./jellybit.db. +# Пример конфигурации jellybit — единый справочник по всем секциям и полям. +# Реальный config.toml не коммитится (содержит секреты), заполняется деплоем. +# Секретные поля здесь оставлены пустыми. По умолчанию загрузчик ищет +# config.toml в рабочей директории; путь переопределяется опцией --config=path. +# Для локального запуска укажите существующие каталоги и db_path -> ./jellybit.db. [qbittorrent] -url = "http://qbit:8989" # по имени сервиса в общей docker-сети -username = "admin" -password = "" -category = "jellybit" # категория для добавляемых jellybit раздач (push) +url = "http://qbit:8989" # адрес qBittorrent WebUI; в docker-сети — по имени сервиса +username = "admin" # логин WebUI +password = "" # секрет: пароль WebUI; обязателен, заполняет деплой +category = "jellybit" # категория для добавляемых jellybit раздач (push, savepath) tag = "jellybit" # тег для усыновления существующих раздач (pull, не двигает файлы) -savepath = "/srv/media/downloads" # qBit кладёт загрузки сюда (задаём при добавлении) +savepath = "/srv/media/downloads" # куда qBittorrent кладёт загрузки (задаём при добавлении) path_map = {} # фолбэк: префикс save_path → хост-префикс, напр. {"/data" = "/srv/media"}; обычно пуст [paths] -downloads = "/srv/media/downloads" -movies = "/srv/media/movies" -series = "/srv/media/series" +# Медиа-песочница на хосте. Каждый путь: абсолютный, без traversal (`..`) и +# должен существовать как доступный каталог (проверяется на старте). Целевые +# movies/series и источник downloads монтируются под единый корень (/srv/media). +downloads = "/srv/media/downloads" # источник: где лежат загрузки qBittorrent (только читаем/линкуем) +movies = "/srv/media/movies" # целевой каталог фильмов для Jellyfin (раскладка хардлинками) +series = "/srv/media/series" # целевой каталог сериалов для Jellyfin (раскладка хардлинками) [storage] -db_path = "/data/jellybit.db" # SQLite на persistent-томе +db_path = "/data/jellybit.db" # путь к файлу SQLite на persistent-томе; обязателен [llm] -type = "openai-compat" +type = "openai-compat" # провайдер распознавания; допустимо: openai-compat # LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal. -base_url = "http://host.docker.internal:1234/v1" -api_key = "" -model = "qwen2.5-32b-instruct" -proxy = "" # опц. HTTP-прокси для удалённых эндпоинтов -timeout = "120s" -max_retries = 3 +base_url = "http://host.docker.internal:1234/v1" # эндпоинт LLM; пусто = распознавание выключено +api_key = "" # секрет: ключ LLM; обязателен, если задан base_url (заполняет деплой) +model = "qwen2.5-32b-instruct" # имя модели на эндпоинте +proxy = "" # опц. HTTP-прокси для удалённых эндпоинтов; пусто = без прокси +timeout = "120s" # таймаут запроса к LLM; Go-duration (s/m/h) +max_retries = 3 # попыток получить валидный ответ LLM; целое ≥ 0 [metadata.tmdb] -enabled = false # включается ключом; без матча авто не делаем -api_key = "" -proxy = "" -timeout = "10s" +enabled = false # включить провайдера TMDB; без матча авто-раскладку не делаем +api_key = "" # секрет: ключ TMDB; обязателен, если enabled (заполняет деплой) +proxy = "" # опц. HTTP-прокси; пусто = без прокси +timeout = "10s" # таймаут запроса к TMDB; Go-duration (s/m/h) [metadata.tvdb] -enabled = false -api_key = "" -proxy = "" -timeout = "10s" +enabled = false # включить провайдера TVDB +api_key = "" # секрет: ключ TVDB; обязателен, если enabled (заполняет деплой) +proxy = "" # опц. HTTP-прокси; пусто = без прокси +timeout = "10s" # таймаут запроса к TVDB; Go-duration (s/m/h) [metadata.tvmaze] -enabled = false # без ключа; только сериалы, тег [tvdbid-…] из externals -proxy = "" -timeout = "10s" +enabled = false # включить провайдера TVMaze; без ключа, только сериалы (тег [tvdbid-…] из externals) +proxy = "" # опц. HTTP-прокси; пусто = без прокси +timeout = "10s" # таймаут запроса к TVMaze; Go-duration (s/m/h) [jellyfin] enabled = false # включить пересканирование медиатеки после раскладки -url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети -api_key = "" # API-ключ Jellyfin (Dashboard → API Keys) -proxy = "" # опц. HTTP-прокси -timeout = "10s" +url = "http://jellyfin:8096" # адрес Jellyfin; обязателен, если enabled (в docker-сети — по имени сервиса) +api_key = "" # секрет: API-ключ Jellyfin (Dashboard → API Keys); обязателен, если enabled +proxy = "" # опц. HTTP-прокси; пусто = без прокси +timeout = "10s" # таймаут запроса к Jellyfin; Go-duration (s/m/h) [worker] -poll_interval = "5s" -stuck_after = "1h" -magnet_timeout = "30m" +poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h) +stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration +magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration [recognition] -auto_confidence_threshold = 0.85 +auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0 [telegram] -enabled = false -token = "" -allowed_user_ids = [] # пусто = запрет всем (fail-closed) -web_base_url = "" # напр. "http://jellybit:8080" — для кнопки «открыть в вебе» -proxy = "" # опц. HTTP-прокси для api.telegram.org +enabled = false # включить Telegram-бота +token = "" # секрет: токен бота; обязателен, если enabled (заполняет деплой) +allowed_user_ids = [] # allowlist Telegram user id (целые); пусто = запрет всем (fail-closed) +web_base_url = "" # база для deep-link «открыть в вебе», напр. "http://jellybit:8080"; пусто = без кнопки +proxy = "" # опц. HTTP-прокси для api.telegram.org; пусто = без прокси [http] -listen = ":8080" -trusted_subnets = [] # ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN); зарезервировано +listen = ":8080" # адрес прослушивания HTTP-сервера; формат [host]:port +trusted_subnets = [] # allowlist подсетей (CIDR); ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN), зарезервировано [log] -level = "info" -format = "json" +level = "info" # уровень логирования; одно из: debug, info, warn, error +format = "json" # формат логов; одно из: json, text diff --git a/docs/conventions/config.md b/docs/conventions/config.md new file mode 100644 index 0000000..512964c --- /dev/null +++ b/docs/conventions/config.md @@ -0,0 +1,123 @@ +# Конфигурация + +Конвенция: *как* устроена и грузится конфигурация jellybit (TOML). +Правила оформления кода (How), не спецификация поведения. + +Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел +«Конвенции кода». + +> Каркас. Загрузчик `internal/config/config.go` уже грузит TOML; валидация +> на старте — в работе (`TODO`), обкатывается на следующем шаге. + +## Принципы + +- **Конфигурация — только TOML.** Env-переменные для конфига **не + используем**: окружение наследуется дочерними процессами и видно через + `/proc//environ` — для секретов это слабее файла под `0600`. +- Грузим **один раз при старте** в одну типизированную структуру `Config` + (под-структуры по секциям). Дальше по коду читаем только её — никаких + `os.Getenv`/чтения файла в бизнес-коде, только загрузчик `internal/config`. +- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса. + +## Файл и поиск + +- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочей + директории** процесса. +- Путь переопределяется опцией **`--config=path`**. +- Образец в репозитории — **`config.example.toml`** (см. ниже); реальный + `config.toml` не коммитится. + +## config.example.toml — самодокументируемый образец + +`config.example.toml` коммитим как единый справочник по конфигу: все секции +и все поля. **Каждое поле снабжаем комментарием**, из которого ясно: + +- **зачем** поле — что оно меняет в поведении; +- **диапазон/допустимые значения** — перечисление или границы; +- **единицы измерения**, если применимо — секунды/миллисекунды, байты/КБ, + доля `0–1` и т.п. + +```toml +[worker] +poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h) +magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration + +[recognition] +auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0 + +[llm] +max_retries = 3 # попыток получить валидный ответ LLM; целое ≥ 0 +``` + +Секретные поля оставляем пустыми — значение приходит из деплоя (см. +«Секреты»). + +## Поля по дискриминатору `type` + +Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного +из бекендов/внешних сервисов — напр. `[llm].type`), обязательность и +опциональность полей определяются значением `type`, а не фиксированы для +секции. + +- **Валидация — по `type`.** Для каждого поддерживаемого `type` свой набор + обязательных полей; поля, относящиеся к другим `type`, не требуются. + Неизвестный `type` → ошибка на старте с перечислением поддерживаемых. +- **Образец — по `type`.** В `config.example.toml`: + - основной (дефолтный) `type` **предзаполнен** рабочими значениями; + - альтернативные `type` — **блоками-комментариями ниже**, каждый со своим + описанием полей (зачем/диапазон/единицы — как у обычных полей); + - так из примера видны все варианты и поля каждого, не открывая код. + +```toml +[llm] +type = "openai-compat" # бекенд LLM; варианты ниже +base_url = "http://host.docker.internal:1234/v1" # эндпоинт OpenAI-совместимого API +api_key = "" # ключ; пусто для keyless-local (LM Studio) +model = "qwen2.5-32b-instruct" # имя модели у провайдера + +# --- альтернативный бекенд: type = "" --- +# [llm] +# type = "" # описание варианта +# ... # его обязательные/опциональные поля +``` + +## Секреты + +Секреты доставляет **деплой**, рендеря их прямо в `config.toml` (jellybit: +Ansible + Vault). Приложение просто читает TOML — отдельного слоя секретов +в коде нет. Источник истины секрета — внешнее хранилище деплоя (Vault), не +репозиторий и не env. + +- Секретные поля jellybit: `qbittorrent.password`, `llm.api_key`, + `metadata.*.api_key`, `jellyfin.api_key`, `telegram.token`. +- Рендеренный `config.toml` (с секретами) **не коммитится**; права `0600`, + владелец — runtime-пользователь (`1000:1000`). +- В `config.example.toml` секретные поля — пустые строки. +- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит + криво отрендеренный файл) — см. «Валидация и fail-fast». +- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность». + +## Валидация и fail-fast + +Конфиг валидируем **на старте, до приёма трафика**. Невалидный конфиг — +лог `ERROR` и выход с ненулевым кодом (не стартуем «наполовину»). + +Что проверяем (jellybit): + +- обязательные поля заданы (напр. `qbittorrent.url`, `paths.*`, + `storage.db_path`); +- пути `paths.movies`/`series`/`downloads` существуют и доступны; целевые — + под единой песочницей (см. инварианты в [CLAUDE.md](../../CLAUDE.md)); +- диапазоны: `recognition.auto_confidence_threshold` ∈ [0, 1], + `llm.max_retries` ≥ 0; +- длительности парсятся (`llm.timeout`, `worker.poll_interval`, …); +- включённые секции консистентны: `metadata.tmdb.enabled` → задан `api_key`; + `jellyfin.enabled` → заданы `url`+`api_key`; `telegram.enabled` → `token`. + +## Структура в коде + +- Весь разбор и валидация — в `internal/config`; наружу отдаётся готовая + `Config`. +- Одна корневая структура `Config` с под-структурами по секциям + (`QBittorrent`, `Paths`, `LLM`, `Metadata`, `Jellyfin`, `Worker`, + `Recognition`, `Telegram`, `HTTP`, `Log`). diff --git a/internal/config/config.go b/internal/config/config.go index 1ffb945..cbbe9de 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..4660f4e --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } + }) + } +} diff --git a/openspec/config.yaml b/openspec/config.yaml index c473fa3..4376ea0 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -34,7 +34,13 @@ context: | Детали: уровни, обязательные поля — docs/conventions/logging.md. - Безопасность: никаких секретов в полях логов (пароли qBittorrent, API-ключи LLM/метабаз, auth-заголовки). - - Ошибки оборачиваем с контекстом (fmt.Errorf("...: %w", err)). + - Конфигурация — только TOML; секреты рендерит деплой (Ansible+Vault) в + файл (config.toml не коммитится, 0600), env для конфига не используем; + валидация на старте. Детали: docs/conventions/config.md. + - Ошибки — stdlib, обёртка с контекстом (fmt.Errorf("...: %w", err)), + проверка errors.Is/errors.As, трансляция доменной ошибки в ответ на + внешней границе (наружу не отдаём текст внутренней ошибки). Детали: + docs/conventions/errors.md. - Время — всегда с явным TZ (сервер в Europe/Moscow; логи — в UTC). # Project context (optional)