Files
transcriber/internal/config/config.go
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

265 lines
14 KiB
Go

package config
import (
"errors"
"fmt"
"net/netip"
"os"
"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"`
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"`
}
// StorageConfig — хранилище сервиса: каталог данных и числа его базы.
//
// Каталог единственный: под ним лежат и база, и файлы записей. Двух путей, как
// было раньше, у хранилища не бывает.
type StorageConfig struct {
DataDir string `toml:"data_dir"`
// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
//
// Ключом, а не константой кода: крутят его при отказе «база занята» под
// несколькими воркерами, и подбор ответа на такой отказ не должен требовать
// пересборки образа.
BusyTimeoutMs int `toml:"busy_timeout_ms"`
// ReadConnections — сколько соединений держит читающий пул. Пишущее
// соединение при этом всегда одно, и настройкой оно не делается: драйвер
// пишет единственным соединением, и второе означало бы отказы по занятости.
ReadConnections int `toml:"read_connections"`
}
// Validate проверяет настройки хранилища. Ноль и отрицательное — опечатка, а не
// режим: нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
// чтения означает пул без предела, то есть настройку, которой не управляют.
func (c StorageConfig) Validate() error {
if strings.TrimSpace(c.DataDir) == "" {
return errors.New("storage: не заполнен ключ data_dir: сервису негде держать базу и файлы записей")
}
if c.BusyTimeoutMs <= 0 {
return errors.New("storage: busy_timeout_ms задаётся положительным числом миллисекунд")
}
if c.ReadConnections <= 0 {
return errors.New("storage: read_connections задаётся положительным числом соединений")
}
return nil
}
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"`
}
// AuthConfig — кому сервис верит на входе.
//
// Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком,
// сходив к провайдеру. Секция поэтому свелась к одному ключу — перечню адресов,
// чьему заголовку верить. Ни адресов провайдера, ни идентификатора клиента, ни
// его секрета здесь больше нет: обменивать код не на что, и секрет ушёл из
// конфига вместе с протоколом.
type AuthConfig struct {
// TrustedProxies — адреса и подсети, чьему заголовку верят. Значение
// сверяется с адресом самого соединения, а не с пересылаемым заголовком:
// пересылаемым распоряжается тот, кто шлёт запрос, и барьер, подделываемый
// той же строкой, которой он обходится, не барьер вовсе.
TrustedProxies []string `toml:"trusted_proxies"`
}
// TrustedNetworks разбирает перечень доверенных адресов.
//
// Одиночный адрес принимается наравне с подсетью и превращается в подсеть на
// один адрес: писать `/32` руками значит помнить разрядность, а перечень читает
// человек.
func (c AuthConfig) TrustedNetworks() ([]netip.Prefix, error) {
networks := make([]netip.Prefix, 0, len(c.TrustedProxies))
for _, raw := range c.TrustedProxies {
value := strings.TrimSpace(raw)
if prefix, err := netip.ParsePrefix(value); err == nil {
networks = append(networks, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(value)
if err != nil {
return nil, fmt.Errorf("auth: %s не читается как адрес или подсеть: %q", trustedProxiesKey, value)
}
networks = append(networks, netip.PrefixFrom(addr, addr.BitLen()))
}
return networks, nil
}
// trustedProxiesKey — имя ключа в отказах старта. Литерал один на файл: два
// разошлись бы молча, и владелец искал бы в конфиге ключ, которого там нет.
const trustedProxiesKey = "trusted_proxies"
// Validate проверяет, что сервису есть кому верить.
//
// Пустой перечень роняет старт. Он значит «не верить никому», то есть сервис,
// поднявшийся никого не узнающим, — и молчать об этом старт не вправе: узнать о
// такой поломке было бы неоткуда, все адреса приложения просто отвечали бы
// отказом.
//
// Нечитаемая строка роняет старт по той же причине: перечень с опечаткой
// проверяется только тем, что кто-то не смог войти.
func (c AuthConfig) Validate() error {
if len(c.TrustedProxies) == 0 {
return fmt.Errorf("auth: не заполнен ключ %s: сервису некому верить, и узнать он никого не сможет", trustedProxiesKey)
}
if _, err := c.TrustedNetworks(); err != nil {
return err
}
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",
// Пять секунд ожидания и четыре читающих соединения: числа выведены
// из числа воркеров по умолчанию, а не из замера. Смысл ожидания —
// пережить чужую запись, а не чужую работу: пишет сервис короткими
// операциями, и очередь из трёх воркеров укладывается в него с
// запасом.
BusyTimeoutMs: 5000,
ReadConnections: 4,
},
Pipeline: PipelineConfig{
Workers: 3,
// Час на свою работу и сутки на чужую. Час меньше времени, которое
// многочасовая запись занимает на приведении, и это принято
// сознательно: сторож ловит зависание, живой шаг наблюдается по
// самому процессу, а остановка обратима.
OwnWorkLimitMinutes: 60,
ForeignWorkLimitMinutes: 24 * 60,
},
Yandex: YandexConfig{
FolderID: "",
SpeechKitAPIKey: "",
ObjStorageAccessKey: "",
ObjStorageSecretKey: "",
ObjStorageBucketName: "",
ObjStorageRegion: "ru-central1",
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
},
// Умолчания у перечня доверенных адресов нет намеренно: подставленное
// значение соврало бы ровно там, где по нему решают, кого пускать.
Auth: AuthConfig{},
}
}
// 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
if _, err := toml.DecodeFile(path, &config); err != nil {
return nil, decodeError(path, err)
}
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)
}