Files
ipam_control/app/rotation.py
T
ayurishchevandClaude Sonnet 5 a846d30872 IPAM Manager: API, UI-админка, журнал аудита
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>
2026-09-20 12:27:47 +03:00

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)