Топология подтверждена перед удалением (docker-compose.prod.yml): tradein-backend (uvicorn app.main:app) — SCHEDULER_ENABLE=false; tradein-scraper (python -m app.scheduler_main) — SCHEDULER_ENABLE=true + USE_KIT_SCHEDULER=true. Kit-путь (_run_kit_scheduler → scraper_kit.orchestration.scheduler + product_handlers) самодостаточен: не импортирует ничего из app.services.scheduler.scheduler_loop или app.services.scrape_pipeline. Все НЕ-sweep джобы, которые kit-scheduler диспетчерит через build_product_handlers, идут напрямую в app.tasks.*/ app.services.* (либо lazy-импортят import_rosreestr_dkp/_execute_cian_backfill из scheduler.py) — мимо удаляемой legacy-машинерии. app/services/scheduler.py: 2098 → 418 строк. Удалено: scheduler_loop, get_due_schedules, reap_zombies, _claim_run, _defer_next_run_at, _spawn_tracked/ _drain_inflight/_inflight_tasks, все 27 trigger_*_run-функций, импорт app.services.scrape_pipeline, константы SCHEDULER_TICK_SEC/ZOMBIE_THRESHOLD_HOURS (достижимы были только через удалённый scheduler_loop-путь). Оставлено (живые импортёры вне удалённого): compute_next_run_at + has_running_run (admin.py), import_rosreestr_dkp + _execute_cian_backfill (lazy-импорты в product_handlers.py — job-тела kit-handler'ов). main.py: убран `from app.services.scheduler import scheduler_loop` + lifespan-блок запуска (`if settings.scheduler_enable: asyncio.create_task(scheduler_loop())`); прод-backend всегда шёл с SCHEDULER_ENABLE=false, так что это был мёртвый код. scheduler_main.py: убрана ship-dark развилка #2192 (USE_KIT_SCHEDULER=false → legacy scheduler_loop fallback) — _run_kit_scheduler() теперь безусловный путь. Поле settings.use_kit_scheduler оставлено в конфиге (Settings extra="ignore" защищает от startup-краха на leftover env var), но на ветвление не влияет. app.services.scrape_pipeline: 0 runtime-импортёров в app/+scripts/+packages/ после этого PR (только тесты, которые Part E удалит вместе с самим файлом) — подтверждено grep. scrape_pipeline.py не тронут (Part E). Тесты: удалены test_house_imv_backfill_scheduler.py (100% legacy-триггер, backfill_house_imv сервис покрыт в test_house_imv_backfill_browser_flag.py / test_backfill_wave2.py) и test_kit_registry_completeness.py (parity-инвариант против удалённого dispatch, дублирует test_scraper_kit_scheduler_parity.py). Точечно вырезаны "Scheduler wiring" секции (trigger_fn_exists/dispatch_branch_ wired/runs_in_executor) из ~10 файлов, тестирующих сами task-функции — сами task-тесты (SQL-shape, миграции, fake-db поведение) оставлены нетронутыми. test_scheduler.py: 825 → ~90 строк (остались только compute_next_run_at-тесты). test_scraper_kit_scheduler_parity.py: убрана golden-parity секция против удалённого scheduler_loop (SOURCE_TO_OLD_TRIGGER/_drive_old_one_tick/ test_routing_parity_per_source), остальное (claim/reap_zombies/dispatch/ registry-shape тесты kit-модуля) сохранено — источник этих инвариантов не app.services.scheduler, а сам scraper_kit.orchestration.scheduler. test_scheduler_main.py: 2 теста, патчившие app.services.scheduler.scheduler_loop, переведены на монкипатч sm._run_kit_scheduler (единственный путь после этого PR). test_sweep_imv_phase.py:171-371 (6 прямых импортов run_avito_city_sweep из scrape_pipeline) намеренно НЕ тронуты — Part E. Verify: полный pytest 3179 passed / 6 skipped / 1 known-unrelated fail (test_search_cache_hit, #2208, не связан с этим PR); ruff 0.7.4 чист на всех изменённых файлах; `python -c "import app.main; import app.scheduler_main"` OK.
189 lines
8.5 KiB
Python
189 lines
8.5 KiB
Python
"""Trade-In MVP — FastAPI entry point.
|
||
|
||
Standalone версия, выделена из основного gendesign repo для локальной разработки.
|
||
Только trade-in фича; никаких других роутеров (concepts/parcels/analytics/etc) нет.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import re
|
||
import secrets
|
||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||
from contextlib import asynccontextmanager
|
||
|
||
import sentry_sdk
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse, Response
|
||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||
|
||
from app.api.v1 import admin, brand, buildings, geocode, lead, me, search, trade_in
|
||
from app.core.auth import get_role
|
||
from app.core.config import settings
|
||
from app.core.db import SessionLocal
|
||
from app.core.fdw import ensure_fdw_user_mapping
|
||
from app.core.ratelimit import RateLimitMiddleware
|
||
from app.observability.sentry_scrub import scrub_pii_event
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||
)
|
||
|
||
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
||
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
|
||
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
|
||
# Logging (logger.error → events). БЕЗ CeleryIntegration — prod не гоняет celery
|
||
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
|
||
# frontend), отдельного broker нет → мониторить нечего.
|
||
if settings.glitchtip_dsn:
|
||
sentry_sdk.init(
|
||
dsn=settings.glitchtip_dsn,
|
||
environment=settings.environment,
|
||
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||
traces_sample_rate=0.0, # только ошибки, без performance-трейсов
|
||
send_default_pii=False, # не шлём client_name / client_phone в отчёты
|
||
before_send=scrub_pii_event, # дочищаем consumer-PII из тела error events
|
||
integrations=[
|
||
StarletteIntegration(),
|
||
FastApiIntegration(),
|
||
SqlalchemyIntegration(),
|
||
HttpxIntegration(),
|
||
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||
],
|
||
)
|
||
logging.getLogger("app.main").info("GlitchTip monitoring enabled")
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||
# #2213 defense-in-depth: если общий секрет не задан — trusted-header auth
|
||
# уязвим к подделке X-Authenticated-User изнутри docker-сети gendesign_shared.
|
||
# Один явный WARNING на старте, чтобы это не осталось незамеченным в проде.
|
||
if not settings.tradein_internal_auth_secret:
|
||
logger.warning(
|
||
"defense-in-depth НЕ активен: X-Authenticated-User принимается без "
|
||
"проверки внутреннего секрета — задай TRADEIN_INTERNAL_AUTH_SECRET в "
|
||
".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
|
||
)
|
||
|
||
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
|
||
# Best-effort: failure does not abort startup, just logs.
|
||
try:
|
||
with SessionLocal() as db:
|
||
ensure_fdw_user_mapping(db)
|
||
except Exception:
|
||
logger.exception("FDW user mapping bootstrap failed — cadastral queries may fail")
|
||
|
||
# #2397 Part C: legacy in-app scheduler launch removed — scheduling lives exclusively
|
||
# in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
|
||
# Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
|
||
# this API process never actually launched scheduler_loop() in production.
|
||
yield
|
||
|
||
|
||
app = FastAPI(
|
||
title="Trade-In MVP API",
|
||
description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
|
||
version="0.1.0",
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
|
||
# (см. app/core/auth.py + auth/roles.yaml). Правила:
|
||
# 1) Любой non-public path требует X-Authenticated-User — иначе 401.
|
||
# 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего
|
||
# не видит» — decided 2026-05-25).
|
||
# 3) /api/v1/admin/* (= внешний /trade-in/api/v1/admin/* после Caddy
|
||
# `uri strip_prefix /trade-in`) — только role=admin, иначе 403.
|
||
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
|
||
# X-Authenticated-User там не приходит из Caddy.
|
||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||
|
||
|
||
@app.middleware("http")
|
||
async def rbac_guard(
|
||
request: Request,
|
||
call_next: Callable[[Request], Awaitable[Response]],
|
||
) -> Response:
|
||
path = request.url.path
|
||
if path in _PUBLIC_PATHS:
|
||
return await call_next(request)
|
||
|
||
username = request.headers.get("X-Authenticated-User")
|
||
if not username:
|
||
return JSONResponse(
|
||
status_code=401,
|
||
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
||
)
|
||
|
||
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
||
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
|
||
# Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401.
|
||
# Constant-time compare против timing-атак. Пусто = защита не активна (fail-open).
|
||
secret = settings.tradein_internal_auth_secret
|
||
if secret:
|
||
provided = request.headers.get("X-Internal-Auth-Secret", "")
|
||
if not secrets.compare_digest(provided, secret):
|
||
logger.warning(
|
||
"RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret "
|
||
"на %s — возможная подделка заголовка мимо Caddy",
|
||
username,
|
||
path,
|
||
)
|
||
return JSONResponse(
|
||
status_code=401,
|
||
content={"detail": "invalid or missing internal auth secret"},
|
||
)
|
||
|
||
try:
|
||
role = get_role(username)
|
||
except KeyError:
|
||
logger.warning("RBAC: unknown user %r tried %s", username, path)
|
||
return JSONResponse(
|
||
status_code=403,
|
||
content={"detail": "user not in roles config"},
|
||
)
|
||
|
||
if _ADMIN_API_RE.match(path) and role != "admin":
|
||
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
|
||
return JSONResponse(
|
||
status_code=403,
|
||
content={"detail": "admin only"},
|
||
)
|
||
return await call_next(request)
|
||
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=settings.cors_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза.
|
||
app.add_middleware(RateLimitMiddleware)
|
||
|
||
|
||
@app.get("/health")
|
||
def health() -> dict[str, str]:
|
||
return {"status": "ok", "environment": settings.environment}
|
||
|
||
|
||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|