diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..368961f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +# В контекст сборки едет ровно один файл — собранный бинарь (см. Dockerfile). +# Всё остальное исключаем: данные измеряются десятками мегабайт и в образе не +# нужны, а лишний контекст замедляет каждую пересборку. +* +!healthlog diff --git a/.gitignore b/.gitignore index 518bbb6..44a1b08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,16 @@ # Сборка /healthlog -# Реальный конфиг (токены), локальная БД и сырой архив +# Реальный конфиг с токенами. Токенов не содержит и потому коммитится: +# config.example.toml (образец) и config.docker.toml (локальный контейнер). /config.toml + +# Данные: база и сырой архив. Один каталог, потому что он же — том контейнера +# (см. docker-compose.yml), и потерять его нельзя. +/data/ *.db *.db-wal *.db-shm -/raw/ # Временные файлы /tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..16fb945 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# Упаковка готового статического бинаря в минимальный образ. +# +# Бинарь собирается СНАРУЖИ, а не внутри образа: +# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o healthlog ./cmd/healthlog +# Так пересборка идёт по горячему кэшу Go на хосте — секунды вместо минут, +# что важно при частом цикле «поправил → перезапустил». Готовит бинарь +# `task build`, от которой зависит `task up`. +# +# distroless/static: без shell и пакетного менеджера, только CA-сертификаты. +# Пользователь задаётся в compose (user: "1000:1000") — чтобы файлы в томе +# принадлежали хозяину каталога, а не root. +FROM gcr.io/distroless/static-debian12 + +COPY healthlog /usr/local/bin/healthlog + +EXPOSE 8080 + +# В distroless нет ни shell, ни curl — проверку делает сам бинарь, порт берёт +# из конфига. Путь к конфигу задаём явно: умолчание загрузчика — config.toml +# в рабочей директории, а конфиг смонтирован в /config. +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD ["/usr/local/bin/healthlog", "healthcheck", "--config", "/config/config.toml"] + +ENTRYPOINT ["/usr/local/bin/healthlog", "serve", "--config", "/config/config.toml"] diff --git a/Taskfile.yml b/Taskfile.yml index 718d0f9..0a018d8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -38,6 +38,61 @@ tasks: cmds: - golangci-lint run + up: + desc: 'Собрать и поднять сервис в контейнере (данные в ./data переживают пересборку)' + deps: [build] + cmds: + - docker compose up -d --build + - task: wait + + down: + desc: Остановить контейнер + cmds: + - docker compose down + + restart: + desc: 'Пересобрать бинарь и перезапустить сервис — основной цикл разработки' + deps: [build] + cmds: + - docker compose up -d --build + - task: wait + + wait: + desc: 'Дождаться, пока сервис ответит на /healthz' + internal: true + cmds: + - | + for i in $(seq 1 30); do + if curl -fsS --max-time 2 http://127.0.0.1:8080/healthz >/dev/null 2>&1; then + echo "healthlog отвечает на :8080"; exit 0 + fi + sleep 1 + done + echo "healthlog не поднялся за 30 секунд, смотри: task logs" >&2 + exit 1 + + logs: + desc: 'Логи сервиса (LINES=N — сколько строк, по умолчанию 50)' + vars: + LINES: '{{.LINES | default "50"}}' + cmds: + - docker compose logs --tail {{.LINES}} healthlog + + ps: + desc: Состояние контейнера + cmds: + - docker compose ps + + gate: + desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты. BASE= — база диффа' + cmds: + - python3 scripts/gate.py {{.BASE}} + + review:context: + desc: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, инвентарь концепций' + cmds: + - python3 scripts/review-context.py + tidy: desc: go mod tidy cmds: diff --git a/config.docker.toml b/config.docker.toml new file mode 100644 index 0000000..df098dd --- /dev/null +++ b/config.docker.toml @@ -0,0 +1,29 @@ +# Конфигурация локального запуска в контейнере (`task up`). +# +# ВНИМАНИЕ: этот файл коммитится. Токенов в нём быть не должно. +# Сейчас проверка токенов выключена сознательно — сервис работает только в +# доверенной локальной сети, телефон шлёт на IP хоста. Перед выездом на +# rivendell токены переезжают в отдельный несохраняемый файл, см. задачу +# беклога «Управление секретами и токенами». + +[server] +addr = ":8080" +read_timeout = "5m" # экспорт истории — десятки мегабайт, бывает медленно +write_timeout = "30s" + +[auth] +write_tokens = [] # ПУСТО = проверка выключена, см. предупреждение выше +read_tokens = [] + +[storage] +# Пути внутри контейнера. Снаружи это каталог ./data, смонтированный в /data +# (см. docker-compose.yml): база и сырой архив переживают пересборку образа. +db_path = "/data/healthlog.db" +archive_dir = "/data/raw" + +[ingest] +max_body_mb = 64 + +[log] +level = "info" +format = "json" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6a11235 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +# Локальный запуск healthlog в контейнере. +# +# Зачем контейнер на этапе разработки: телефон шлёт данные непрерывно, и +# сервис должен переживать пересборку без ручного вмешательства. `task up` +# собирает бинарь и поднимает сервис, `task restart` перезапускает его, +# `task logs` показывает поток. Данные лежат в ./data и переживают всё. +# +# Наружу открыт порт 8080 на всех интерфейсах — иначе телефон из локальной +# сети не достучится. Токенов нет: сеть доверенная, см. config.docker.toml. + +services: + healthlog: + image: healthlog:dev + build: + context: . + dockerfile: Dockerfile + container_name: healthlog + # Тот же uid, что у хозяина каталога: файлы в ./data остаются нашими, а не + # root, иначе их не прочитать скриптами разведки с хоста. + user: "1000:1000" + ports: + - "8080:8080" + volumes: + - ./config.docker.toml:/config/config.toml:ro + - ./data:/data + restart: unless-stopped + stop_grace_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/scripts/diff-coverage.py b/scripts/diff-coverage.py new file mode 100755 index 0000000..e9ec139 --- /dev/null +++ b/scripts/diff-coverage.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Покрытие изменённых строк тестами. + +Складывает `go test -coverprofile` и `git diff -U0 `: показывает, какие +изменённые исполняемые строки не покрыты ни одним тестом. Общий процент по +пакету бесполезен для ревью — важно, покрыт ли именно новый код. + +Использование: scripts/diff-coverage.py +""" + +import re +import subprocess +import sys +from collections import defaultdict + +HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") +BLOCK = re.compile(r"^(.+):(\d+)\.\d+,(\d+)\.\d+ (\d+) (\d+)$") + + +def module_path() -> str: + with open("go.mod", encoding="utf-8") as f: + for line in f: + if line.startswith("module "): + return line.split(None, 1)[1].strip() + return "" + + +def coverage_blocks(profile: str, module: str): + """file -> [(start, end, count)] по репо-относительным путям.""" + blocks = defaultdict(list) + with open(profile, encoding="utf-8") as f: + for line in f: + m = BLOCK.match(line.strip()) + if not m: + continue + path, start, end, _stmts, count = m.groups() + if module and path.startswith(module + "/"): + path = path[len(module) + 1:] + blocks[path].append((int(start), int(end), int(count))) + return blocks + + +def changed_lines(base: str): + """file -> {номера добавленных/изменённых строк} для нетестовых .go.""" + out = subprocess.run( + ["git", "diff", "-U0", base, "--", "*.go"], + capture_output=True, text=True, check=True, + ).stdout + changed = defaultdict(set) + current = None + for line in out.splitlines(): + if line.startswith("+++ b/"): + path = line[6:] + current = None if path.endswith("_test.go") else path + elif line.startswith("@@") and current: + m = HUNK.match(line) + if m: + start = int(m.group(1)) + count = int(m.group(2) or 1) + changed[current].update(range(start, start + count)) + return changed + + +def main() -> int: + if len(sys.argv) != 3: + print(__doc__, file=sys.stderr) + return 2 + profile, base = sys.argv[1], sys.argv[2] + + blocks = coverage_blocks(profile, module_path()) + changed = changed_lines(base) + + total = uncovered = 0 + report = [] + for path in sorted(changed): + gaps = [] + for line in sorted(changed[path]): + covering = [b for b in blocks.get(path, []) if b[0] <= line <= b[1]] + if not covering: + continue # не исполняемая строка (объявление, комментарий, скобка) + total += 1 + if all(b[2] == 0 for b in covering): + uncovered += 1 + gaps.append(line) + if gaps: + report.append((path, gaps)) + + if total == 0: + print("изменённых исполняемых строк нет (или профиль не содержит этих пакетов)") + return 0 + + print(f"изменённых исполняемых строк: {total}, не покрыто: {uncovered}" + f" ({100 * (total - uncovered) // total}% покрытия диффа)") + for path, gaps in report: + print(f" {path}: {compact(gaps)}") + return 0 + + +def compact(lines): + """[3,4,5,9] -> '3-5,9'.""" + out, start, prev = [], lines[0], lines[0] + for line in lines[1:] + [None]: + if line == prev + 1: + prev = line + continue + out.append(str(start) if start == prev else f"{start}-{prev}") + start = prev = line + return ",".join(out) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gate.py b/scripts/gate.py new file mode 100755 index 0000000..903aaa7 --- /dev/null +++ b/scripts/gate.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Детерминированный гейт ревью. + +Прогоняет всё, у чего есть объективный оракул, и печатает сводку. В отличие от +`task test`/`task lint` не останавливается на первом отказе — ревьюверу нужна +полная картина, а не первая упавшая команда. + +Использование: scripts/gate.py [] + base-rev — база для диффа. По умолчанию merge-base с master; на самом + master — HEAD~1. + +Шаги выбираются по изменённым файлам: правка документации не гоняет тесты и +линтеры. Пропущенный шаг всегда виден в сводке с причиной — молча пропущенная +проверка даёт ложное ощущение проверенности, а это ровно то, ради чего гейт и +заводился. + +Коды возврата: 0 — красных шагов нет, 1 — есть. +Статусы: OK, FAIL (краснит гейт), WARN (виден, но не блокирует), SKIP. +""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +OUT_DIR = Path("tmp/gate") + +OK, FAIL, WARN, SKIP = "OK", "FAIL", "WARN", "SKIP" + +summary: list[tuple[str, str, str]] = [] + + +def record(status: str, name: str, hint: str = "") -> None: + summary.append((status, name, hint)) + + +def git(*args: str) -> str: + return subprocess.run( + ["git", *args], capture_output=True, text=True, check=True + ).stdout.strip() + + +def base_rev(argv: list[str]) -> str: + if len(argv) > 1: + return argv[1] + on_master = git("rev-parse", "--abbrev-ref", "HEAD") == "master" + has_master = subprocess.run( + ["git", "rev-parse", "--verify", "-q", "master"], capture_output=True + ).returncode == 0 + if has_master and not on_master: + return git("merge-base", "HEAD", "master") + return "HEAD~1" + + +def changed_files(base: str) -> list[str]: + """Изменённые файлы: закоммиченное относительно базы + рабочее дерево + новые. + + Берём объединение намеренно: гейт гоняют и до коммита, и после, и лишний + прогон шага дешевле пропущенного. + """ + files = set(git("diff", "--name-only", base).splitlines()) + files |= set(git("ls-files", "--others", "--exclude-standard").splitlines()) + return sorted(f for f in files if f) + + +def run(name: str, cmd: list[str], env: dict[str, str] | None = None) -> bool: + """Выполняет шаг, складывает вывод в tmp/gate/.log.""" + log = OUT_DIR / f"{name}.log" + full_env = {**os.environ, **(env or {})} + proc = subprocess.run(cmd, capture_output=True, text=True, env=full_env) + log.write_text(proc.stdout + proc.stderr, encoding="utf-8") + return proc.returncode == 0 + + +def step(name: str, cmd: list[str], hint: str = "", env: dict[str, str] | None = None) -> bool: + ok = run(name, cmd, env) + record(OK, name) if ok else record( + FAIL, name, f"{hint + ' → ' if hint else ''}{OUT_DIR}/{name}.log" + ) + return ok + + +def main() -> int: + OUT_DIR.mkdir(parents=True, exist_ok=True) + base = base_rev(sys.argv) + changed = changed_files(base) + + go_changed = any(f.endswith(".go") for f in changed) + deps_changed = any(f in ("go.mod", "go.sum") for f in changed) + migrations_changed = any(f.startswith("internal/store/migrations/") for f in changed) + config_changed = any(f.startswith("internal/config/") for f in changed) + code_changed = go_changed or deps_changed + no_code = "нет изменений в .go/go.mod — код не трогали" + + print(f"== gate: база диффа {base}, изменённых файлов {len(changed)} ==") + if not code_changed: + print(" код не менялся — go-шаги пропускаются, см. сводку") + + # --- Компиляция и статика --- + if code_changed: + step("build", ["go", "build", "./..."], "не собирается") + step("vet", ["go", "vet", "./..."]) + if shutil.which("golangci-lint"): + step("lint", ["golangci-lint", "run"]) + else: + record(SKIP, "lint", "golangci-lint не установлен (task setup)") + + unformatted = [ + f for f in subprocess.run( + ["gofmt", "-l", "."], capture_output=True, text=True + ).stdout.split() + if not f.startswith("tmp/") + ] + if unformatted: + record(FAIL, "gofmt", "не отформатировано: " + " ".join(unformatted)) + else: + record(OK, "gofmt") + else: + for name in ("build", "vet", "lint", "gofmt"): + record(SKIP, name, no_code) + + # --- Тесты --- + if code_changed: + tests_ok = step("test", ["go", "test", "-count=1", "./..."]) + # Флаки: повторный прогон. Тест, который иногда зелёный, не является + # оракулом ни для чего, поэтому расхождение — находка не ниже major. + # Стабильно красный набор флаки не проверяем: его разбирает шаг test. + if tests_ok: + if run("test-repeat", ["go", "test", "-count=1", "./..."]): + record(OK, "flaky") + else: + record(FAIL, "flaky", "прогон 1 зелёный, прогон 2 красный — флаки-тест") + else: + record(SKIP, "flaky", "набор красный — сперва чиним test") + else: + record(SKIP, "test", no_code) + record(SKIP, "flaky", no_code) + + # --- Гонки --- + if not code_changed: + record(SKIP, "race", no_code) + elif shutil.which("gcc"): + step( + "race", + ["go", "test", "-race", "-count=1", "./..."], + "гонка либо сборка тестов — смотри лог", + env={"CGO_ENABLED": "1"}, + ) + else: + record(SKIP, "race", "нет gcc: -race требует cgo. Гонки НЕ проверены") + + # --- Покрытие изменённых строк --- + if not go_changed: + record(SKIP, "diff-coverage", "нет изменений в .go") + elif run("cover", ["go", "test", "-count=1", f"-coverprofile={OUT_DIR}/cover.out", "./..."]): + if run("diff-coverage", ["python3", "scripts/diff-coverage.py", f"{OUT_DIR}/cover.out", base]): + record(OK, "diff-coverage", (OUT_DIR / "diff-coverage.log").read_text().splitlines()[0]) + else: + record(SKIP, "diff-coverage", f"не удалось посчитать → {OUT_DIR}/diff-coverage.log") + else: + record(SKIP, "diff-coverage", f"прогон с профилем не собрался → {OUT_DIR}/cover.log") + + # --- Миграции на чистой схеме --- + if migrations_changed or go_changed: + step( + "migrations", + ["go", "test", "-count=1", "-run", "Migration", "./internal/store/..."], + "миграции не накатываются с нуля", + ) + else: + record(SKIP, "migrations", "миграции и код не менялись") + + # --- ER-схема синхронна с миграциями --- + # Меняем структуру — обновляем ER-схему в том же change. Проверка по + # диффу, потому и живёт здесь, а не в правиле линтера. + if migrations_changed: + if "docs/database.md" in changed: + record(OK, "er-schema") + else: + record(FAIL, "er-schema", "миграция изменена, а docs/database.md — нет") + + # --- Образцы конфигурации синхронны с его структурой --- + # Конвенция: config.example.toml самодокументируемый и полный. Забытое поле + # обнаруживается не тестом, а тем, что через полгода никто не знает о его + # существовании, — поэтому проверяем механически. + if config_changed: + samples = [s for s in ("config.example.toml", "config.docker.toml") if s not in changed] + if samples: + record(FAIL, "config-samples", + "internal/config изменён, а образцы — нет: " + ", ".join(samples)) + else: + record(OK, "config-samples") + + # --- Секреты --- + # Гоняем всегда: секрет утекает из любого файла, не только из кода. Для + # healthlog это ещё и данные о здоровье — они чувствительнее токенов. + if shutil.which("gitleaks"): + step("gitleaks", ["gitleaks", "git", "--no-banner"], "возможен секрет в истории/индексе") + else: + record(SKIP, "gitleaks", "gitleaks не установлен") + + # --- Данные не утекли в репозиторий --- + # Каталог ./data (база + сырой архив) под .gitignore. Попадание любого его + # файла в индекс означает утечку выгрузок Apple Health в историю git, + # откуда их уже не убрать простым коммитом. + tracked_data = [f for f in git("ls-files").splitlines() + if f.startswith("data/") or f.endswith((".db", ".db-wal", ".db-shm"))] + if tracked_data: + record(FAIL, "no-health-data", + "данные о здоровье под контролем версий: " + ", ".join(tracked_data[:5])) + else: + record(OK, "no-health-data") + + # --- Уязвимости зависимостей --- + # Не блокирует: находка тут — состояние зависимостей и тулчейна, а не диффа. + if not code_changed: + record(SKIP, "govulncheck", no_code) + elif shutil.which("govulncheck"): + if run("govulncheck", ["govulncheck", "./..."]): + record(OK, "govulncheck") + else: + # Ненулевой код возврата означает и найденные уязвимости, и отказ + # самого инструмента (чаще всего код не собирается). Различаем: без + # этого «уязвимостей: 0» выглядит как проверка, которой не было. + found = (OUT_DIR / "govulncheck.log").read_text().count("\nVulnerability #") + if found: + record(WARN, "govulncheck", + f"достижимо из кода уязвимостей: {found} → {OUT_DIR}/govulncheck.log") + else: + record(SKIP, "govulncheck", + f"не отработал (обычно код не собирается) → {OUT_DIR}/govulncheck.log") + else: + record(SKIP, "govulncheck", "govulncheck не установлен (task setup)") + + # --- Сводка --- + print("\n== сводка ==") + for status, name, hint in summary: + print(f"{status:<5} {name:<15} {hint}") + + if any(s == FAIL for s, _, _ in summary): + print("\nГЕЙТ КРАСНЫЙ — опиниативные проходы не запускаются") + return 1 + print("\nгейт зелёный (шаги WARN и SKIP см. в сводке — они идут в находки" + " и в границы покрытия)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/review-context.py b/scripts/review-context.py new file mode 100755 index 0000000..fbe68df --- /dev/null +++ b/scripts/review-context.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Вход для архитектурного прохода ревью: то, чего нет в диффе. + +Агент, видящий только `git diff`, физически не может судить об архитектуре — он +не знает, какие понятия в проекте уже есть и как они называются. Скрипт собирает +дерево пакетов с назначением, граф внутренних зависимостей и инвентарь +существующих концепций. + +Публичную поверхность пакетов намеренно НЕ выгружаем: дамп `go doc -short` по +всему модулю занимал бы больше половины вывода, а агент вытянет `go doc` по +нужному пакету сам. Здесь — только то, что иначе не восстановить. + +Использование: scripts/review-context.py [> tmp/review-context.md] +""" + +import re +import subprocess +import sys +from pathlib import Path + + +def go(*args: str) -> str: + return subprocess.run( + ["go", *args], capture_output=True, text=True, check=True + ).stdout.strip() + + +def module_path() -> str: + for line in Path("go.mod").read_text(encoding="utf-8").splitlines(): + if line.startswith("module "): + return line.split(None, 1)[1].strip() + return "" + + +def scan(root: str, pattern: str) -> list[str]: + """Строки нетестовых .go файлов под root, совпавшие с pattern.""" + base = Path(root) + if not base.exists(): + return [] + re_ = re.compile(pattern) + found = set() + for path in sorted(base.rglob("*.go")): + if path.name.endswith("_test.go"): + continue + for line in path.read_text(encoding="utf-8").splitlines(): + if re_.search(line): + found.add(line.strip()) + return sorted(found) + + +def listdir(root: str) -> list[str]: + base = Path(root) + return sorted(p.name for p in base.iterdir()) if base.is_dir() else [] + + +def block(title: str, lines: list[str], lang: str = "") -> None: + print(f"### {title}\n") + print(f"```{lang}") + print("\n".join(lines) if lines else "— пусто") + print("```\n") + + +def main() -> int: + mod = module_path() + packages = [p for p in go("list", "./...").splitlines() if not p.endswith("/migrations")] + + print("# Контекст проекта для архитектурного ревью\n") + print(f"Сгенерировано `scripts/review-context.py`. Модуль: `{mod}`.\n") + + print("## Пакеты и назначение\n") + print("```") + for entry in go("list", "-f", "{{.ImportPath}}|{{.Doc}}", "./...").splitlines(): + path, _, doc = entry.partition("|") + short = path.removeprefix(mod + "/") + print(f"{short:<34} {doc or '— (нет doc-комментария пакета)'}") + print("```\n") + + print("## Граф внутренних зависимостей\n") + print("Только импорты внутри модуля. Стрелка A -> B означает «A зависит от B».\n") + print("```") + for pkg in packages: + imports = go("list", "-f", '{{range .Imports}}{{.}}\n{{end}}', pkg).splitlines() + deps = sorted({i.removeprefix(mod + "/") for i in imports if i.startswith(mod + "/")}) + if deps: + print(f"{pkg.removeprefix(mod + '/')} -> {' '.join(deps)}") + print("```\n") + + print("## Инвентарь концепций\n") + print("Как в проекте уже называются вещи. Новое понятие вводим, только" + " убедившись,\nчто его нельзя выразить существующими.\n") + + block("Доменные ошибки (sentinel)", scan("internal", r"^var Err\w+ = errors\.New")) + block("Секции и поля конфигурации", scan("internal/config", r'toml:"')) + block("Миграции (порядок = порядок эволюции схемы)", + listdir("internal/store/migrations")) + block("Маршруты HTTP", scan("internal/httpapi", r'r\.(Get|Post|Put|Delete|Route|Mount)\(')) + block("Слои гранулярности и прочие перечисления домена", + scan("internal", r'^\s*Layer\w+\s+\w*Layer\s*=|^const .*Layer')) + block("Capabilities OpenSpec", listdir("openspec/specs")) + + print("## Что держать в голове\n") + print("""healthlog — хранилище, а не аналитика. Инварианты, которые архитектурный +проход обязан защищать (подробно — `CLAUDE.md` и `docs/architecture.md`): + +- точки хранятся дословно; всё, что теряет содержимое точки, — находка; +- идентичность по координатам (`метрика + слой + метка`), `source` в ключ + не входит; +- агрегации при записи нет; свёртка живёт только в ответе и только с + измеренным родом метрики; +- нижний слой HAE не суммируется никогда — это интерполяция, а не сэмплы; +- секреты и тела запросов не попадают в логи: данные о здоровье + чувствительнее токенов. +""") + return 0 + + +if __name__ == "__main__": + sys.exit(main())