добавлен приём пакетов Health Auto Export

- POST /api/v1/ingest: токен, лимит тела, gzip, durable-запись тела в сырой
  архив, учёт доставки в SQLite
- код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое
  содержимое — 200, данные уже сохранены и доразберутся позже
- сохраняется полный набор заголовков запроса с вычисткой секретов по имени
  и по совпадению значения с токеном
This commit is contained in:
av
2026-08-01 12:37:17 +03:00
parent 5e2385ba6e
commit 41f2f05f2f
20 changed files with 1903 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"context"
"flag"
"fmt"
"net/http"
"strings"
"time"
"git.vakhrushev.me/av/healthlog/internal/config"
)
func runHealthcheck(args []string) error {
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
cfgPath := fs.String("config", config.DefaultPath, "путь к config.toml")
timeout := fs.Duration("timeout", 5*time.Second, "таймаут запроса")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
url := "http://" + localAddr(cfg.Server.Addr) + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("healthz: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("healthz: статус %d", resp.StatusCode)
}
return nil
}
// localAddr превращает адрес прослушивания в адрес для обращения к себе:
// ":8080" слушает все интерфейсы, но стучаться надо в конкретный.
func localAddr(addr string) string {
if strings.HasPrefix(addr, ":") {
return "127.0.0.1" + addr
}
return addr
}
+48
View File
@@ -0,0 +1,48 @@
// Команда healthlog — коллектор данных Apple Health из Health Auto Export.
//
// Подкоманды:
//
// healthlog [serve] --config <path> принимать пакеты (по умолчанию)
// healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
package main
import (
"os"
"strings"
"git.vakhrushev.me/av/healthlog/internal/logging"
)
func main() {
args := os.Args[1:]
// Первый позиционный аргумент (не флаг) — подкоманда. Без него (и при
// `--config ...`) запускаем сервис.
cmd := "serve"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
cmd, args = args[0], args[1:]
}
var err error
switch cmd {
case "serve":
err = runServe(args)
case "healthcheck":
err = runHealthcheck(args)
default:
_, _ = os.Stderr.WriteString("unknown command: " + cmd + "\n")
os.Exit(2)
}
if err != nil {
if cmd == "serve" {
// Фатальный сбой старта сервиса — структурный лог ERROR (как в
// проде), затем ненулевой код возврата.
logging.NewStderr().Error("fatal startup", "command", cmd, "error", err)
} else {
// Диагностический CLI — человекочитаемый stderr, это
// пользовательский вывод, а не лог сервиса.
_, _ = os.Stderr.WriteString("fatal: " + err.Error() + "\n")
}
os.Exit(1)
}
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os/signal"
"syscall"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/config"
"git.vakhrushev.me/av/healthlog/internal/httpapi"
"git.vakhrushev.me/av/healthlog/internal/ingest"
"git.vakhrushev.me/av/healthlog/internal/logging"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// shutdownTimeout — сколько ждём завершения активных запросов при остановке.
// Приём может быть в середине записи многомегабайтного тела в архив.
const shutdownTimeout = 30 * time.Second
func runServe(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
cfgPath := fs.String("config", config.DefaultPath, "путь к config.toml")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
log := logging.New(cfg.Log.Level, cfg.Log.Format)
st, err := store.Open(cfg.Storage.DBPath)
if err != nil {
return err
}
defer func() { _ = st.Close() }()
arch, err := archive.New(cfg.Storage.ArchiveDir)
if err != nil {
return err
}
if len(cfg.Auth.WriteTokens) == 0 {
log.Warn("write auth disabled", "reason", "auth.write_tokens пуст")
}
handler := httpapi.New(httpapi.Options{
Ingest: ingest.New(arch, st, log),
Log: log,
WriteTokens: cfg.Auth.WriteTokens,
MaxBodyMB: cfg.Ingest.MaxBodyMB,
})
srv := &http.Server{
Addr: cfg.Server.Addr,
Handler: handler,
// ReadTimeout щедрый (большой пакет по мобильной сети), но заголовки
// обязаны приехать быстро — иначе полуоткрытое соединение держит слот.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: cfg.Server.ReadTimeout.D(),
WriteTimeout: cfg.Server.WriteTimeout.D(),
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
log.Info("server started",
"addr", cfg.Server.Addr,
"db_path", cfg.Storage.DBPath,
"archive_dir", arch.Root(),
"max_body_mb", cfg.Ingest.MaxBodyMB)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("listen: %w", err)
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
log.Info("server stopping")
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
return nil
}