Backend (FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 16): - организации, VRF, префиксы (дерево, использование, автоназначение), адреса, операторы связи, устройства и типы устройств; JWT, роли admin/viewer; - VRF принадлежит организации (составной FK), смена VRF у префикса переносит поддерево, имя VRF уникально в организации; - журнал аудита: поиск и фильтры, ротация (срок/количество), очистка по паролю с блокировкой, IP клиента и метаданные запроса (X-Forwarded-For только от TRUSTED_PROXIES). UI (web/, без сборки): экраны и диалоги по макетам «IPAM Manager», кликабельные строки реестров, локальные шрифты IBM Plex, собственные выпадающие списки. Окружение: docker-compose (postgres + app), миграции Alembic 0001-0004, scripts/gen_env.py, scripts/seed_demo.py, 11 автотестов (pytest). Документация: README.md и docs/changes/001-005 (планы и итоги). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
75 lines
3.1 KiB
Python
75 lines
3.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
|
|
from app.services import SYSTEM, audit
|
|
|
|
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
|
|
by_count = db.execute(delete(AuditLog).where(AuditLog.id.in_(select(AuditLog.id).order_by(AuditLog.id).limit(n)))).rowcount
|
|
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)
|