Повторный анализ кодовой базы (docs/reviews/2026-09-26-codebase-review-2.md) и доработки:
025 Ёмкость префикса — размер его подсети (а не сумма листьев); «Обзор» считает ёмкость
по корневым активным IPv4-префиксам и адреса внутри них.
026 Политика блокировки входа: 5 неудач на логин+IP, 20 на IP, 50 на логин со всех IP,
кроме известных IP (known_logins, миграция 0009) — владельца нельзя заблокировать анонимно.
027 Сериализация попыток входа по IP (advisory-lock после блокировки логина).
028 UI «Префиксы»: загрузка всех страниц (до 20 000), счётчики по total, предупреждение об усечении.
029 Advisory-lock по VRF для операций, меняющих дерево префиксов и раскладку адресов.
030 Исправление замечаний ревью 025-029: _lock_prefix (VRF блокируется до чтения префикса,
409 при одновременном переносе), константы политики входа перенесены в app/services.py.
Тесты: 14 passed (проверка ёмкости родителя приведена к семантике 025); сквозные сценарии
и гонки — docs/reviews/2026-09-26-changes-025-029-review.md, 2026-09-27-changes-030-review.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
81 lines
4.1 KiB
Python
81 lines
4.1 KiB
Python
"""Настройки и ротация журнала: удаление записей старше срока и самых старых сверх лимита."""
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import delete, func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.db import SessionLocal
|
|
from app.models import AppSetting, AuditLog, KnownLogin, LoginAttempt
|
|
from app.services import KNOWN_IP_DAYS, SYSTEM, audit # KNOWN_IP_DAYS — из app.services, не из API-слоя (изменение 030, ревью 025-029 находка №2)
|
|
|
|
log = logging.getLogger("ipam.rotation")
|
|
DEFAULTS = {"retention_days": 90, "max_entries": 100_000}
|
|
LOCK_KEY = 703001 # advisory lock: одна ротация за раз, даже при нескольких репликах
|
|
INTERVAL_SECONDS = 3600
|
|
|
|
|
|
def get_settings(db: Session) -> dict:
|
|
row = db.get(AppSetting, "journal")
|
|
return {**DEFAULTS, **(row.value if row else {})}
|
|
|
|
|
|
def save_settings(db: Session, values: dict) -> None:
|
|
row = db.get(AppSetting, "journal")
|
|
if row is None:
|
|
db.add(AppSetting(key="journal", value=values))
|
|
else:
|
|
row.value = values
|
|
|
|
|
|
def rotate(db: Session) -> dict | None:
|
|
"""Возвращает {'by_age', 'by_count'} или None, если ротацию уже выполняет другой процесс."""
|
|
if not db.scalar(select(func.pg_try_advisory_xact_lock(LOCK_KEY))):
|
|
db.rollback()
|
|
return None
|
|
cfg = get_settings(db)
|
|
by_age = by_count = 0
|
|
if cfg["retention_days"] > 0:
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=cfg["retention_days"])
|
|
by_age = db.execute(delete(AuditLog).where(AuditLog.ts < cutoff)).rowcount
|
|
if cfg["max_entries"] > 0:
|
|
total = db.scalar(select(func.count()).select_from(AuditLog)) or 0
|
|
if total > cfg["max_entries"]:
|
|
# удаляем с запасом в одну запись — под сводную запись о ротации, чтобы итог не превышал лимит
|
|
n = total - cfg["max_entries"] + 1
|
|
# сначала неудачные и заблокированные входы (их может нагенерировать кто угодно), затем самые старые записи остальных типов (изменение 024, находка №4)
|
|
noisy = db.execute(delete(AuditLog).where(AuditLog.id.in_(
|
|
select(AuditLog.id).where(AuditLog.entity_type == "session", AuditLog.action.in_(("failed", "locked"))).order_by(AuditLog.id).limit(n)))).rowcount
|
|
rest = n - noisy
|
|
by_count = noisy + (db.execute(delete(AuditLog).where(AuditLog.id.in_(select(AuditLog.id).order_by(AuditLog.id).limit(rest)))).rowcount if rest > 0 else 0)
|
|
db.execute(delete(LoginAttempt).where(LoginAttempt.ts < datetime.now(timezone.utc) - timedelta(days=1)))
|
|
db.execute(delete(KnownLogin).where(KnownLogin.last_seen < datetime.now(timezone.utc) - timedelta(days=KNOWN_IP_DAYS))) # изменение 026
|
|
if by_age or by_count:
|
|
parts = []
|
|
if by_age:
|
|
parts.append(f"старше {cfg['retention_days']} дн.: {by_age}")
|
|
if by_count:
|
|
parts.append(f"сверх лимита {cfg['max_entries']}: {by_count}")
|
|
audit(db, SYSTEM, "journal", None, "rotated", "rotation", {"by_age": by_age, "by_count": by_count},
|
|
message="Ротация журнала: удалено " + "; ".join(parts))
|
|
db.commit()
|
|
return {"by_age": by_age, "by_count": by_count}
|
|
|
|
|
|
def run_rotation() -> None:
|
|
try:
|
|
with SessionLocal() as db:
|
|
result = rotate(db)
|
|
if result and (result["by_age"] or result["by_count"]):
|
|
log.info("journal rotated: %s", result)
|
|
except Exception: # фоновая задача не должна падать
|
|
log.exception("journal rotation failed")
|
|
|
|
|
|
async def rotation_loop() -> None:
|
|
await asyncio.sleep(30)
|
|
while True:
|
|
await asyncio.to_thread(run_rotation)
|
|
await asyncio.sleep(INTERVAL_SECONDS)
|