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) }