feat: add safe diagnostics export
This commit is contained in:
@@ -88,7 +88,7 @@ docker compose up --build
|
||||
|
||||
Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным.
|
||||
|
||||
API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`.
|
||||
API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров.
|
||||
|
||||
Остановка:
|
||||
|
||||
|
||||
+20
-2
@@ -258,8 +258,26 @@ def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/diagnostics")
|
||||
def admin_diagnostics(_: Annotated[str, Depends(_admin)]) -> dict[str, str]:
|
||||
return {"version": settings.app_version, "revision": settings.app_revision, "environment": settings.deployment_environment}
|
||||
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
|
||||
report_counts = {status.value: count for status, count in db.execute(
|
||||
select(CatchReport.moderation_status, func.count()).group_by(CatchReport.moderation_status)
|
||||
)}
|
||||
observation_counts = {status: count for status, count in db.execute(
|
||||
select(ExternalObservation.status, func.count()).group_by(ExternalObservation.status)
|
||||
)}
|
||||
payload = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"build": {"version": settings.app_version, "revision": settings.app_revision, "environment": settings.deployment_environment},
|
||||
"counts": {
|
||||
"catch_reports": report_counts,
|
||||
"external_observations": observation_counts,
|
||||
"data_sources": db.scalar(select(func.count()).select_from(DataSource)) or 0,
|
||||
"enabled_data_sources": db.scalar(select(func.count()).select_from(DataSource).where(DataSource.enabled.is_(True))) or 0,
|
||||
"official_import_runs": db.scalar(select(func.count()).select_from(OfficialRecordImport)) or 0,
|
||||
"community_import_runs": db.scalar(select(func.count()).select_from(CommunityImportRun)) or 0,
|
||||
},
|
||||
}
|
||||
return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"})
|
||||
|
||||
|
||||
@app.get("/api/v1/imports", response_model=list[ImportRunOut])
|
||||
|
||||
@@ -89,7 +89,13 @@ def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
|
||||
assert client.get("/api/v1/admin/diagnostics").status_code == 401
|
||||
response = client.get("/api/v1/admin/diagnostics", headers={"Authorization": "Bearer change-me-in-production"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"version": "0.1.0", "revision": "dev", "environment": "development"}
|
||||
payload = response.json()
|
||||
assert payload["build"] == {"version": "0.1.0", "revision": "dev", "environment": "development"}
|
||||
assert set(payload) == {"generated_at", "build", "counts"}
|
||||
assert response.headers["Content-Disposition"] == "attachment; filename=rf4spotter-diagnostics.json"
|
||||
serialized = response.text.lower()
|
||||
for forbidden in ("player_name", "source_url", "error_summary", "raw_payload", "admin_token", "s3_"):
|
||||
assert forbidden not in serialized
|
||||
|
||||
|
||||
def test_spot_detail_and_catches() -> None:
|
||||
|
||||
@@ -42,6 +42,12 @@ curl -fsS https://rf4spotter.ru/health
|
||||
curl -fsS https://rf4spotter.ru/ready
|
||||
```
|
||||
|
||||
Безопасный диагностический снимок для администратора:
|
||||
|
||||
```bash
|
||||
curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" -o rf4spotter-diagnostics.json https://rf4spotter.ru/api/v1/admin/diagnostics
|
||||
```
|
||||
|
||||
После обновления DNS и получения сертификатов выполните внешний этап той же проверки:
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@
|
||||
- [x] Настроить долгий immutable cache для хешированных assets и разумный cache для изображений/favicon (7 сентября 2026).
|
||||
- [x] Добавить серверное «Показать ещё» для публичной ленты полевых сигналов с сохранением фильтров и пределом 48 записей (7 сентября 2026).
|
||||
- [x] Объединять одинаковые полевые сигналы в сюжеты без потери уникальных ссылок provenance и показывать число совпадений (7 сентября 2026).
|
||||
- [ ] Добавить безопасный административный экспорт диагностики без персональных данных.
|
||||
- [x] Добавить защищённый JSON-экспорт диагностики со сборкой и агрегированными счётчиками без персональных данных, URL и ошибок источников (7 сентября 2026).
|
||||
- [x] Контролировать рост PostgreSQL и MinIO host-monitor'ом с настраиваемыми порогами и runbook реакции (7 сентября 2026).
|
||||
- [ ] Добавить фоновую проверку битых исходных ссылок с соблюдением лимитов источников.
|
||||
- [x] Показывать версию и commit SHA в readiness и защищённой административной диагностике (7 сентября 2026).
|
||||
|
||||
Reference in New Issue
Block a user