Files
transcriber/internal/config/config.go
T
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00

271 lines
13 KiB
Go

package config
import (
"errors"
"fmt"
"net/url"
"os"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type Config struct {
Server ServerConfig `toml:"server"`
Storage StorageConfig `toml:"storage"`
Pipeline PipelineConfig `toml:"pipeline"`
Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"`
Auth AuthConfig `toml:"auth"`
}
// PipelineConfig — настройки конвейера расшифровки.
type PipelineConfig struct {
// Workers — число одинаковых воркеров. Ноль — законное значение: сервис
// поднимается, записи принимаются и не двигаются. Это режим, а не поломка.
Workers int `toml:"workers"`
// OwnWorkLimitMinutes — предел простоя записи там, где работу делаем мы
// сами. Сторож ловит **зависание**, а не долгую работу: живой шаг
// наблюдается по самому процессу, а остановка обратима — снятие признака
// возвращает запись на её рубеж.
OwnWorkLimitMinutes int `toml:"own_work_limit_minutes"`
// ForeignWorkLimitMinutes — предел простоя там, где ждём чужую операцию.
// Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибка
// идёт в сторону долгого: ложная остановка хуже поздней.
ForeignWorkLimitMinutes int `toml:"foreign_work_limit_minutes"`
}
// StuckLimits переводит настройки в пределы простоя.
func (c PipelineConfig) StuckLimits() entity.StuckLimits {
return entity.StuckLimits{
Own: time.Duration(c.OwnWorkLimitMinutes) * time.Minute,
Foreign: time.Duration(c.ForeignWorkLimitMinutes) * time.Minute,
}
}
// Validate проверяет числа конвейера. Отрицательное число воркеров — ошибка
// настройки, а не режим: ноль объявлен законным значением, и отличать его от
// опечатки обязан старт.
func (c PipelineConfig) Validate() error {
if c.Workers < 0 {
return errors.New("pipeline: число воркеров не может быть отрицательным")
}
if c.OwnWorkLimitMinutes <= 0 || c.ForeignWorkLimitMinutes <= 0 {
return errors.New("pipeline: пределы простоя задаются положительным числом минут")
}
return nil
}
type ServerConfig struct {
Port int `toml:"port"`
ShutdownTimeout int `toml:"shutdown_timeout"`
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
UsersWhiteList []string `toml:"users_while_list"`
}
// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
// записей. Двух путей, как было раньше, у хранилища не бывает.
type StorageConfig struct {
DataDir string `toml:"data_dir"`
}
type YandexConfig struct {
FolderID string `toml:"folder_id"`
SpeechKitAPIKey string `toml:"speech_kit_api_key"`
ObjStorageAccessKey string `toml:"object_storage_access_key_id"`
ObjStorageSecretKey string `toml:"object_storage_secret_access_key"`
ObjStorageBucketName string `toml:"object_storage_bucket_name"`
ObjStorageRegion string `toml:"object_storage_region"`
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
}
// TelegramConfig — вход Telegram. Признак включения объявляет намерение
// владельца, `BotToken` означает только доступ. Пока два значения жили в одном
// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал»,
// и сервис поднимался без бота в обоих случаях.
type TelegramConfig struct {
// Enabled — умолчания у него нет **намеренно**, и потому его нет в
// `defaultConfig()`: умолчание было бы угаданным намерением, а признак
// заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит
// `LoadConfig` — нулевое значение `bool` режима не выбирает.
Enabled bool `toml:"enabled"`
BotToken string `toml:"bot_token"`
UpdateTimeout int `toml:"update_timeout"`
}
// Validate проверяет ключ доступа против объявленного намерения. Пустой ключ
// при включённом входе — ошибка настройки: бот по нему не появится, а тихий
// подъём без бота оставил бы отправителей без ответов.
//
// Названо имя ключа, а не значение: значение `bot_token` в журнал попасть не
// должно.
func (c TelegramConfig) Validate() error {
if c.Enabled && c.BotToken == "" {
return errors.New("telegram: не заполнен ключ bot_token при enabled = true")
}
return nil
}
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
// секрет, положенный однажды шагом, не пережил бы ротации.
type AuthConfig struct {
AuthURL string `toml:"auth_url"`
TokenURL string `toml:"token_url"`
UserInfoURL string `toml:"user_info_url"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
// RedirectURL — адрес возврата, тот же, что записан клиенту у провайдера.
RedirectURL string `toml:"redirect_url"`
// SecureCookie — признак `Secure` у куки сессии. Умолчание «включено»;
// выключается только для локального запуска по `http://localhost`, где
// браузер такую куку не сохранит.
SecureCookie bool `toml:"secure_cookie"`
}
// Validate проверяет, что вход настроен целиком и что адреса — адреса. Пустое
// или негодное поле роняет старт: с молча выключенным входом сервис поднялся бы
// открытым наружу, а узнать об этом было бы неоткуда.
//
// Форма адреса проверяется здесь, а не только хранилищем, потому что хранилище
// отвергает негодный адрес позже — из хука подъёма, до регистрации пробы
// здоровья и метрик. Тогда владелец не получает даже кода состояния: сервис
// молча падает целиком, вместе с ботом и воркерами.
func (c AuthConfig) Validate() error {
values := map[string]string{
"auth_url": c.AuthURL,
"token_url": c.TokenURL,
"user_info_url": c.UserInfoURL,
"client_id": c.ClientID,
"client_secret": c.ClientSecret,
"redirect_url": c.RedirectURL,
}
missing := make([]string, 0, len(values))
for name, value := range values {
if value == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
// Названы имена ключей, а не значения: значение `client_secret` в
// сообщение об ошибке попасть не должно, оно уедет в журнал.
return fmt.Errorf("auth: не заполнены ключи: %s", strings.Join(missing, ", "))
}
malformed := make([]string, 0, 4)
for _, name := range []string{"auth_url", "token_url", "user_info_url", "redirect_url"} {
parsed, err := url.Parse(values[name])
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
malformed = append(malformed, name)
}
}
if len(malformed) > 0 {
return fmt.Errorf("auth: ключи не похожи на адрес: %s", strings.Join(malformed, ", "))
}
return nil
}
// DefaultConfig returns a Config with default values
func defaultConfig() *Config {
return &Config{
Server: ServerConfig{
Port: 8080,
ShutdownTimeout: 5,
ForceShutdownTimeout: 20,
},
Storage: StorageConfig{
DataDir: "data",
},
Pipeline: PipelineConfig{
Workers: 3,
// Час на свою работу и сутки на чужую. Час меньше времени, которое
// многочасовая запись занимает на приведении, и это принято
// сознательно: сторож ловит зависание, живой шаг наблюдается по
// самому процессу, а остановка обратима.
OwnWorkLimitMinutes: 60,
ForeignWorkLimitMinutes: 24 * 60,
},
Yandex: YandexConfig{
FolderID: "",
SpeechKitAPIKey: "",
ObjStorageAccessKey: "",
ObjStorageSecretKey: "",
ObjStorageBucketName: "",
ObjStorageRegion: "ru-central1",
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
},
// Умолчания у `Enabled` здесь нет намеренно — причина у поля.
Telegram: TelegramConfig{
BotToken: "",
UpdateTimeout: 10,
},
Auth: AuthConfig{
SecureCookie: true,
},
}
}
// LoadConfig loads configuration from a TOML file
func LoadConfig(path string) (*Config, error) {
// Check if file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, fmt.Errorf("config file not found: %s", path)
}
config := defaultConfig()
// Load configuration from file
meta, err := toml.DecodeFile(path, &config)
if err != nil {
return nil, decodeError(path, err)
}
// Признак включения входа Telegram обязателен: умолчания у него нет, и
// отличить «не задан» от «задан ложным» умеет только разбор — нулевое
// значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу
// она не отдаётся, приговор выносится здесь.
if !meta.IsDefined("telegram", "enabled") {
return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram")
}
return config, nil
}
// decodeError переводит отказ разбора на свои слова. Пересказывать библиотеку
// нельзя: она собирает текст отказа из разбираемого куска файла, и оборванная
// строка секретного ключа уехала бы в журнал вместе со значением.
//
// Разрез идёт по семейству отказа, и значения несёт только одно:
//
// - `toml.ParseError` — сюда сведены отказы лексера и разбора значения, а его
// `Message` собран из разбираемого куска («Invalid float value: %q»,
// «invalid duration: %q»). Берём строку, столбец и последний ключ — они
// безопасны, — а `Message` не берём;
// - прочие отказы декодера собраны из имён ключей и имён типов, значений в них
// нет вовсе. Их текст берём как есть: выбросив его, мы заплатили бы
// разборчивостью отказа там, где платить не за что.
//
// Две ветки не сводятся в одну намеренно. Сведённая к общему знаменателю, она
// либо вернёт утечку, либо оставит несовпадение типов без единого намёка.
func decodeError(path string, err error) error {
var parseErr toml.ParseError
if errors.As(err, &parseErr) {
if parseErr.LastKey != "" {
return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d, последний ключ %q",
path, parseErr.Position.Line, parseErr.Position.Col, parseErr.LastKey)
}
return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d",
path, parseErr.Position.Line, parseErr.Position.Col)
}
return fmt.Errorf("failed to decode config file %s: %w", path, err)
}