добавлен приём пакетов Health Auto Export
- POST /api/v1/ingest: токен, лимит тела, gzip, durable-запись тела в сырой архив, учёт доставки в SQLite - код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое содержимое — 200, данные уже сохранены и доразберутся позже - сохраняется полный набор заголовков запроса с вычисткой секретов по имени и по совпадению значения с токеном
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
// Package config загружает конфигурацию healthlog из TOML-файла.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
// DefaultPath — имя конфига по умолчанию: ищется в рабочей директории
|
||||
// процесса. Переопределяется опцией --config=path.
|
||||
const DefaultPath = "config.toml"
|
||||
|
||||
// Config — корневая конфигурация сервиса (см. config.example.toml).
|
||||
type Config struct {
|
||||
Server Server `toml:"server"`
|
||||
Auth Auth `toml:"auth"`
|
||||
Storage Storage `toml:"storage"`
|
||||
Ingest Ingest `toml:"ingest"`
|
||||
Log Log `toml:"log"`
|
||||
}
|
||||
|
||||
// Server — параметры HTTP-сервера.
|
||||
type Server struct {
|
||||
Addr string `toml:"addr"`
|
||||
// ReadTimeout — на всё чтение запроса вместе с телом. Держим щедрым:
|
||||
// экспорт истории — это десятки мегабайт по мобильной сети.
|
||||
ReadTimeout Duration `toml:"read_timeout"`
|
||||
WriteTimeout Duration `toml:"write_timeout"`
|
||||
}
|
||||
|
||||
// Auth — токены доступа. Пустой список = проверка выключена (локальный
|
||||
// запуск в доверенной сети); сервис предупреждает об этом на старте.
|
||||
type Auth struct {
|
||||
WriteTokens []string `toml:"write_tokens"`
|
||||
ReadTokens []string `toml:"read_tokens"`
|
||||
}
|
||||
|
||||
// Storage — где лежат сырой архив и витрина.
|
||||
type Storage struct {
|
||||
DBPath string `toml:"db_path"`
|
||||
ArchiveDir string `toml:"archive_dir"`
|
||||
}
|
||||
|
||||
// Ingest — параметры приёма.
|
||||
type Ingest struct {
|
||||
MaxBodyMB int `toml:"max_body_mb"`
|
||||
}
|
||||
|
||||
// Log — уровень и формат логов.
|
||||
type Log struct {
|
||||
Level string `toml:"level"`
|
||||
Format string `toml:"format"`
|
||||
}
|
||||
|
||||
// Load читает TOML-конфиг, подставляет умолчания и валидирует результат.
|
||||
// Отсутствующий файл — не ошибка: сервис поднимается на умолчаниях, что
|
||||
// нужно для локального запуска.
|
||||
func Load(path string) (*Config, error) {
|
||||
var cfg Config
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
// Конфига нет — работаем на умолчаниях.
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
default:
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.applyDefaults()
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
if c.Server.Addr == "" {
|
||||
c.Server.Addr = ":8080"
|
||||
}
|
||||
if c.Server.ReadTimeout == 0 {
|
||||
c.Server.ReadTimeout = Duration(5 * time.Minute)
|
||||
}
|
||||
if c.Server.WriteTimeout == 0 {
|
||||
c.Server.WriteTimeout = Duration(30 * time.Second)
|
||||
}
|
||||
if c.Storage.DBPath == "" {
|
||||
c.Storage.DBPath = "./healthlog.db"
|
||||
}
|
||||
if c.Storage.ArchiveDir == "" {
|
||||
c.Storage.ArchiveDir = "./raw"
|
||||
}
|
||||
if c.Ingest.MaxBodyMB == 0 {
|
||||
c.Ingest.MaxBodyMB = 64
|
||||
}
|
||||
if c.Log.Level == "" {
|
||||
c.Log.Level = "info"
|
||||
}
|
||||
if c.Log.Format == "" {
|
||||
c.Log.Format = "json"
|
||||
}
|
||||
}
|
||||
|
||||
// validate собирает все проблемы разом (errors.Join), чтобы не чинить конфиг
|
||||
// по одной строчке за перезапуск.
|
||||
func (c *Config) validate() error {
|
||||
var errs []error
|
||||
|
||||
if c.Ingest.MaxBodyMB <= 0 {
|
||||
errs = append(errs, fmt.Errorf("ingest.max_body_mb: %w: %d", ErrOutOfRange, c.Ingest.MaxBodyMB))
|
||||
}
|
||||
if c.Server.ReadTimeout <= 0 {
|
||||
errs = append(errs, fmt.Errorf("server.read_timeout: %w", ErrOutOfRange))
|
||||
}
|
||||
if c.Server.WriteTimeout <= 0 {
|
||||
errs = append(errs, fmt.Errorf("server.write_timeout: %w", ErrOutOfRange))
|
||||
}
|
||||
if !oneOf(c.Log.Level, "debug", "info", "warn", "error") {
|
||||
errs = append(errs, fmt.Errorf("log.level: %w: %q", ErrUnknownValue, c.Log.Level))
|
||||
}
|
||||
if !oneOf(c.Log.Format, "json", "text") {
|
||||
errs = append(errs, fmt.Errorf("log.format: %w: %q", ErrUnknownValue, c.Log.Format))
|
||||
}
|
||||
for i, t := range c.Auth.WriteTokens {
|
||||
if strings.TrimSpace(t) == "" {
|
||||
errs = append(errs, fmt.Errorf("auth.write_tokens[%d]: %w", i, ErrEmptyToken))
|
||||
}
|
||||
}
|
||||
for i, t := range c.Auth.ReadTokens {
|
||||
if strings.TrimSpace(t) == "" {
|
||||
errs = append(errs, fmt.Errorf("auth.read_tokens[%d]: %w", i, ErrEmptyToken))
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Ошибки валидации конфига.
|
||||
var (
|
||||
ErrOutOfRange = errors.New("значение вне допустимого диапазона")
|
||||
ErrUnknownValue = errors.New("недопустимое значение")
|
||||
ErrEmptyToken = errors.New("пустой токен")
|
||||
)
|
||||
|
||||
func oneOf(v string, allowed ...string) bool {
|
||||
for _, a := range allowed {
|
||||
if strings.EqualFold(v, a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Duration — time.Duration, читаемая из TOML строкой Go-формата ("30s", "5m").
|
||||
type Duration time.Duration
|
||||
|
||||
// UnmarshalText разбирает Go-длительность из TOML-строки.
|
||||
func (d *Duration) UnmarshalText(text []byte) error {
|
||||
v, err := time.ParseDuration(string(text))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse duration %q: %w", text, err)
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// D возвращает значение как time.Duration.
|
||||
func (d Duration) D() time.Duration { return time.Duration(d) }
|
||||
@@ -0,0 +1,111 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/config"
|
||||
)
|
||||
|
||||
// Отсутствующий конфиг — не ошибка: локальный запуск должен работать «из
|
||||
// коробки», без файла.
|
||||
func TestLoadMissingFileUsesDefaults(t *testing.T) {
|
||||
cfg, err := config.Load(filepath.Join(t.TempDir(), "нет-такого.toml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.Addr != ":8080" {
|
||||
t.Errorf("addr = %q, ожидалось \":8080\"", cfg.Server.Addr)
|
||||
}
|
||||
if cfg.Ingest.MaxBodyMB != 64 {
|
||||
t.Errorf("max_body_mb = %d, ожидалось 64", cfg.Ingest.MaxBodyMB)
|
||||
}
|
||||
if cfg.Server.ReadTimeout.D() != 5*time.Minute {
|
||||
t.Errorf("read_timeout = %v, ожидалось 5m", cfg.Server.ReadTimeout.D())
|
||||
}
|
||||
if len(cfg.Auth.WriteTokens) != 0 {
|
||||
t.Errorf("write_tokens = %v, ожидался пустой список", cfg.Auth.WriteTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadsFile(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
[server]
|
||||
addr = "127.0.0.1:9000"
|
||||
read_timeout = "1m"
|
||||
|
||||
[auth]
|
||||
write_tokens = ["секрет"]
|
||||
|
||||
[ingest]
|
||||
max_body_mb = 8
|
||||
|
||||
[log]
|
||||
level = "debug"
|
||||
format = "text"
|
||||
`)
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.Addr != "127.0.0.1:9000" {
|
||||
t.Errorf("addr = %q", cfg.Server.Addr)
|
||||
}
|
||||
if cfg.Server.ReadTimeout.D() != time.Minute {
|
||||
t.Errorf("read_timeout = %v", cfg.Server.ReadTimeout.D())
|
||||
}
|
||||
if cfg.Ingest.MaxBodyMB != 8 {
|
||||
t.Errorf("max_body_mb = %d", cfg.Ingest.MaxBodyMB)
|
||||
}
|
||||
if len(cfg.Auth.WriteTokens) != 1 || cfg.Auth.WriteTokens[0] != "секрет" {
|
||||
t.Errorf("write_tokens = %v", cfg.Auth.WriteTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация собирает все проблемы разом (errors.Join), а не чинится по одной
|
||||
// за перезапуск.
|
||||
func TestValidateReportsAllProblems(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
[ingest]
|
||||
max_body_mb = -1
|
||||
|
||||
[auth]
|
||||
write_tokens = [" "]
|
||||
|
||||
[log]
|
||||
level = "нет-такого"
|
||||
format = "yaml"
|
||||
`)
|
||||
|
||||
_, err := config.Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка валидации")
|
||||
}
|
||||
|
||||
for _, want := range []error{config.ErrOutOfRange, config.ErrUnknownValue, config.ErrEmptyToken} {
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("ошибка не содержит %v: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsBrokenTOML(t *testing.T) {
|
||||
if _, err := config.Load(writeConfig(t, "это не toml =")); err == nil {
|
||||
t.Fatal("ожидалась ошибка разбора")
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("подготовка конфига: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
Reference in New Issue
Block a user