Files
ipam_control/app/rotation.py
T

80 lines
4.1 KiB
Python
Raw Normal View History

"""Настройки и ротация журнала: удаление записей старше срока и самых старых сверх лимита."""
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)