import ipaddress from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import String, and_, cast, func, or_, select, text, update from sqlalchemy.orm import Session from app import schemas as s from app.db import get_db from app.models import Address, AddressStatus, Device, Organization, Prefix, PrefixStatus, User, Vrf from app.security import admin_user, current_user from app.services import ( MAX_CAPACITY, MAX_OFFSET, apply_update, audit, blockers, capacity, commit, count, flush, contains, free_page, get_or_404, network_role, next_free_address, next_free_subnet, refuse_delete, utilization, ) router = APIRouter(dependencies=[Depends(current_user)], tags=["prefixes"]) FREE_LISTING_LIMIT = 4096 # «свободные» строки показываем, только если подсеть не больше /20 # -------------------------------------------------------------------- prefixes def _usage(db: Session, ids: list[int]) -> dict[int, tuple[int, int]]: """{prefix_id: (assigned, stored)} с учётом вложенных префиксов того же VRF.""" if not ids: return {} p, c = Prefix.__table__.alias("p"), Prefix.__table__.alias("c") a = Address.__table__ rows = db.execute( select( p.c.id, func.count().filter(a.c.status == AddressStatus.assigned), func.count(a.c.id), ) .select_from(p.join(c, and_(c.c.vrf_id == p.c.vrf_id, c.c.prefix.op("<<=")(p.c.prefix))) .join(a, a.c.prefix_id == c.c.id)) .where(p.c.id.in_(ids)) .group_by(p.c.id) ).all() return {r[0]: (r[1], r[2]) for r in rows} def _depths(db: Session, organization_id: int) -> dict[int, int]: parents = dict(db.execute(select(Prefix.id, Prefix.parent_id).where(Prefix.organization_id == organization_id)).all()) def depth(i: int) -> int: d = 0 while parents.get(i) is not None: i, d = parents[i], d + 1 return d return {i: depth(i) for i in parents} def _capacities(db: Session, organization_id: int) -> dict[int, int]: """Ёмкость листа — размер подсети; ёмкость родителя — сумма ёмкостей вложенных листьев.""" rows = db.execute(select(Prefix.id, Prefix.parent_id, Prefix.prefix).where(Prefix.organization_id == organization_id)).all() kids: dict[int, list[int]] = {} for i, parent, _ in rows: if parent is not None: kids.setdefault(parent, []).append(i) own = {i: capacity(str(cidr)) for i, _, cidr in rows} memo: dict[int, int] = {} def cap(i: int) -> int: if i not in memo: memo[i] = sum(cap(k) for k in kids[i]) if i in kids else own[i] return memo[i] return {i: min(cap(i), MAX_CAPACITY) for i in own} def _prefix_outs(db: Session, rows: list[Prefix]) -> list[s.PrefixOut]: usage = _usage(db, [r.id for r in rows]) depths: dict[int, int] = {} caps: dict[int, int] = {} vrf_names = dict(db.execute(select(Vrf.id, Vrf.name).where(Vrf.id.in_(list({r.vrf_id for r in rows})))).all()) if rows else {} for org_id in {r.organization_id for r in rows}: depths.update(_depths(db, org_id)) caps.update(_capacities(db, org_id)) out = [] for r in rows: used, stored = usage.get(r.id, (0, 0)) cap = caps.get(r.id, capacity(str(r.prefix))) out.append(s.PrefixOut( id=r.id, organization_id=r.organization_id, vrf_id=r.vrf_id, vrf_name=vrf_names[r.vrf_id], prefix=str(r.prefix), family=ipaddress.ip_network(str(r.prefix)).version, description=r.description, status=r.status, parent_id=r.parent_id, depth=depths.get(r.id, 0), is_pool=r.is_pool, note=r.note, used=used, capacity=cap, utilization=utilization(used, cap), addresses_count=stored, )) return out def attach_to_tree(db: Session, p: Prefix, keep_parent: bool = False, exclude: frozenset[int] = frozenset()) -> None: """Вписывает префикс в дерево его VRF: находит самого узкого родителя и забирает под себя вложенные префиксы, чей текущий родитель шире (exclude — уже подчинённые ему, при переносе поддерева).""" cidr = str(p.prefix) if not keep_parent: p.parent_id = db.scalar( select(Prefix.id) .where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op(">>")(cidr)) .order_by(func.masklen(Prefix.prefix).desc()).limit(1) ) plen = ipaddress.ip_network(cidr).prefixlen inner = db.scalars( select(Prefix).where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op("<<")(cidr)) ).all() for c in inner: if c.id in exclude: continue cur = db.get(Prefix, c.parent_id) if c.parent_id else None if cur is None or ipaddress.ip_network(str(cur.prefix)).prefixlen < plen: c.parent_id = p.id def _narrowest_prefix(db: Session, vrf_id: int, ip: str) -> Prefix | None: """Самый узкий префикс VRF, содержащий адрес.""" return db.scalar(select(Prefix).where(Prefix.vrf_id == vrf_id, Prefix.prefix.op(">>=")(ip)).order_by(func.masklen(Prefix.prefix).desc()).limit(1)) def _unusable_after_rehome(db: Session, p: Prefix) -> list[tuple[str, str]]: """(адрес, CIDR целевого префикса) для адресов VRF префикса p (его диапазон), которые при переносе в самый узкий целевой префикс (как в rehome_addresses) окажутся его адресом сети или broadcast — такой перенос запрещён (изменение 024, находка №2). Целевой префикс — не обязательно p: при переносе VRF им может оказаться уже существующий вложенный префикс целевого VRF, поэтому в паре возвращается именно он, а не p.""" db.flush() rows = db.execute(text( "SELECT host(a2.address) AS ip, (SELECT x.prefix FROM prefixes x WHERE x.vrf_id = a2.vrf_id AND x.prefix >>= a2.address " " ORDER BY masklen(x.prefix) DESC LIMIT 1) AS cidr " "FROM addresses a2 WHERE a2.vrf_id = :vrf AND a2.address <<= :cidr"), {"vrf": p.vrf_id, "cidr": str(p.prefix)}).all() return [(ip, cidr) for ip, cidr in rows if network_role(ipaddress.ip_network(cidr), ipaddress.ip_address(ip))] def _unusable_message(bad: list[tuple[str, str]]) -> str: items = ", ".join(f"{ip} ({cidr})" for ip, cidr in bad) return f"Адреса {items} станут адресом сети/broadcast: освободите их или выберите другой префикс" def rehome_addresses(db: Session, p: Prefix) -> int: """Приводит адреса диапазона префикса к правилу «адрес — в самом узком префиксе VRF» (новый вложенный забирает адреса родителя из своего диапазона; перенесённый префикс — адреса целевого VRF). Возвращает число перенесённых.""" db.flush() return db.execute(text( "UPDATE addresses a SET prefix_id = t.best FROM (" " SELECT a2.id AS aid, (SELECT x.id FROM prefixes x WHERE x.vrf_id = a2.vrf_id AND x.prefix >>= a2.address ORDER BY masklen(x.prefix) DESC LIMIT 1) AS best " " FROM addresses a2 WHERE a2.vrf_id = :vrf AND a2.address <<= :cidr) t " "WHERE a.id = t.aid AND t.best IS NOT NULL AND t.best <> a.prefix_id"), {"vrf": p.vrf_id, "cidr": str(p.prefix)}).rowcount def _subtree(db: Session, root: Prefix) -> list[Prefix]: """Префикс и все вложенные по цепочке parent_id.""" result, frontier = [root], [root.id] while frontier: kids = db.scalars(select(Prefix).where(Prefix.parent_id.in_(frontier))).all() result += kids frontier = [k.id for k in kids] return result def _move_to_vrf(db: Session, p: Prefix, vrf_id: int) -> tuple[dict, int]: """Переносит префикс с поддеревом в другой VRF той же организации; возвращает (diff, число префиксов).""" vrf = get_or_404(db, Vrf, vrf_id, "VRF") if vrf.organization_id != p.organization_id: raise HTTPException(422, "Целевой VRF принадлежит другой организации") subtree = _subtree(db, p) taken = db.scalars(select(Prefix.prefix).where(Prefix.vrf_id == vrf.id, Prefix.prefix.in_([str(m.prefix) for m in subtree]))).all() if taken: raise HTTPException(409, f"В VRF «{vrf.name}» уже есть: {', '.join(str(x) for x in taken)}") clash = db.scalars( select(func.host(Address.address)).where( Address.prefix_id.in_([m.id for m in subtree]), Address.address.in_(select(Address.address).where(Address.vrf_id == vrf.id)), ).limit(10) ).all() if clash: raise HTTPException(409, f"В VRF «{vrf.name}» уже назначены адреса: {', '.join(clash)}") old_name = p.vrf.name for m in subtree: m.vrf = vrf p.parent_id = None db.flush() db.expire_all() # vrf_id адресов обновлён каскадом БД attach_to_tree(db, p, exclude=frozenset(m.id for m in subtree)) bad = _unusable_after_rehome(db, p) if bad: # commit ещё не выполнялся — исключение уходит без частичных изменений (rollback при закрытии сессии) raise HTTPException(422, _unusable_message(bad)) rehome_addresses(db, p) return {"vrf": f"{old_name} → {vrf.name}", "moved": len(subtree)}, len(subtree) @router.get("/prefixes", response_model=s.Page[s.PrefixOut]) def list_prefixes( organization_id: int | None = None, vrf_id: int | None = None, status: PrefixStatus | None = None, family: int | None = Query(None, ge=4, le=6), q: str = "", limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db), ): stmt = select(Prefix) if organization_id: stmt = stmt.where(Prefix.organization_id == organization_id) if vrf_id: stmt = stmt.where(Prefix.vrf_id == vrf_id) if status: stmt = stmt.where(Prefix.status == status) if family: stmt = stmt.where(func.family(Prefix.prefix) == (4 if family == 4 else 6)) if q: stmt = stmt.where(or_(contains(cast(Prefix.prefix, String), q), contains(Prefix.description, q))) total = count(db, stmt) rows = db.scalars(stmt.order_by(Prefix.vrf_id, Prefix.prefix).limit(limit).offset(offset)).all() return s.Page(items=_prefix_outs(db, list(rows)), total=total) @router.get("/prefixes/{id}", response_model=s.PrefixOut) def get_prefix(id: int, db: Session = Depends(get_db)): return _prefix_outs(db, [get_or_404(db, Prefix, id, "Префикс")])[0] @router.post("/prefixes", response_model=s.PrefixOut, status_code=201) def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): vrf = get_or_404(db, Vrf, body.vrf_id, "VRF") if vrf.organization_id != body.organization_id: raise HTTPException(422, "VRF принадлежит другой организации") get_or_404(db, Organization, body.organization_id, "Организация") if db.scalar(select(Prefix.id).where(Prefix.vrf_id == vrf.id, Prefix.prefix == body.prefix)): raise HTTPException(409, "Такой префикс уже есть в этом VRF") parent_id = body.parent_id if parent_id is not None: parent = get_or_404(db, Prefix, parent_id, "Родительский префикс") if parent.vrf_id != vrf.id or not ipaddress.ip_network(body.prefix).subnet_of(ipaddress.ip_network(str(parent.prefix))): raise HTTPException(422, "Родительский префикс должен содержать новый и быть в том же VRF") p = Prefix(**{**body.model_dump(), "parent_id": parent_id}) db.add(p) flush(db, "Такой префикс уже есть в этом VRF") attach_to_tree(db, p, keep_parent=parent_id is not None) bad = _unusable_after_rehome(db, p) if bad: msg = _unusable_message(bad) # до rollback: после него объект p истекает db.rollback() raise HTTPException(422, msg) moved = rehome_addresses(db, p) audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name, **({"moved_addresses": moved} if moved else {})}) commit(db, "Такой префикс уже есть в этом VRF") return _prefix_outs(db, [p])[0] def _busy_ranges(db: Session, p: Prefix) -> list[tuple[int, int]]: """Занятые диапазоны внутри префикса: вложенные префиксы того же VRF (любой глубины) и адреса, записанные в самом префиксе.""" busy = [] for c in db.scalars(select(Prefix.prefix).where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op("<<")(str(p.prefix)))): n = ipaddress.ip_network(str(c)) busy.append((int(n.network_address), int(n.broadcast_address))) busy += [(int(ipaddress.ip_address(a)),) * 2 for a in db.scalars(select(func.host(Address.address)).where(Address.prefix_id == p.id))] return busy def _find_subnet(db: Session, parent: Prefix, length: int) -> tuple[str | None, int, int]: """(свободный блок | None, min длина, max длина) для вложенного префикса в parent.""" net = ipaddress.ip_network(str(parent.prefix)) lo, hi = net.prefixlen + 1, net.max_prefixlen if not lo <= length <= hi: raise HTTPException(422, f"Размер вложенного префикса: от /{lo} до /{hi}" if lo <= hi else "Префикс нельзя дробить: это одиночный адрес") return next_free_subnet(str(parent.prefix), length, _busy_ranges(db, parent)), lo, hi @router.get("/prefixes/{id}/subnets/next", response_model=s.SubnetPreview) def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Depends(get_db)): """Предпросмотр: какой блок будет выделен, без создания.""" found, lo, hi = _find_subnet(db, get_or_404(db, Prefix, id, "Префикс"), length) return s.SubnetPreview(prefix=found, length_min=lo, length_max=hi) @router.post("/prefixes/{id}/subnets/next", response_model=s.PrefixOut, status_code=201) def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): """Создаёт вложенный префикс заданного размера в первом свободном выровненном блоке родителя.""" parent = db.scalar(select(Prefix).where(Prefix.id == id).with_for_update()) # сериализуем параллельные выделения из одного родителя if parent is None: raise HTTPException(404, "Префикс не найден") found, _, _ = _find_subnet(db, parent, body.length) if found is None: raise HTTPException(409, f"В префиксе {parent.prefix} нет свободного блока /{body.length}") p = Prefix(organization_id=parent.organization_id, vrf_id=parent.vrf_id, prefix=found, parent_id=parent.id, description=body.description, status=body.status, is_pool=body.is_pool, note=body.note) db.add(p) flush(db, "Такой префикс уже есть в этом VRF, повторите запрос") attach_to_tree(db, p, keep_parent=True) moved = rehome_addresses(db, p) # адреса родителя из выделенного блока (учтены и при выборе блока, но вдруг появились параллельно) audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix), **({"moved_addresses": moved} if moved else {})}) commit(db, "Такой префикс уже есть в этом VRF, повторите запрос") return _prefix_outs(db, [p])[0] @router.patch("/prefixes/{id}", response_model=s.PrefixOut) def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)): p = get_or_404(db, Prefix, id, "Префикс") data = body.model_dump(exclude_unset=True, exclude_none=True) new_vrf = data.pop("vrf_id", None) changed = {k: str(v) for k, v in apply_update(p, data).items()} if new_vrf is not None and new_vrf != p.vrf_id: changed.update(_move_to_vrf(db, p, new_vrf)[0]) audit(db, user, "prefix", p, "updated", str(p.prefix), changed) commit(db) return _prefix_outs(db, [p])[0] @router.delete("/prefixes/{id}", status_code=204) def delete_prefix(id: int, force: bool = False, db: Session = Depends(get_db), user: User = Depends(admin_user)): p = get_or_404(db, Prefix, id, "Префикс") used = None if force else blockers(db, select(func.host(Address.address)).where(Address.prefix_id == id).order_by(Address.address)) if used: refuse_delete(db, user, "prefix", p, str(p.prefix), "В префиксе есть адреса; удалите их или используйте force=true", {"addresses": used}) db.execute(update(Prefix).where(Prefix.parent_id == id).values(parent_id=p.parent_id)) gone = count(db, select(Address.id).where(Address.prefix_id == id)) if force else 0 audit(db, user, "prefix", p, "deleted", str(p.prefix), {"force": True, "addresses_deleted": gone} if gone else None) db.delete(p) commit(db) # ------------------------------------------------------------------- addresses def _addr_out(a: Address, device_name: str | None = None) -> s.AddressOut: return s.AddressOut( id=a.id, prefix_id=a.prefix_id, address=s.ip_text(a.address), status=a.status.value, dns_name=a.dns_name, description=a.description, device_id=a.device_id, device_name=device_name, note=a.note, updated_at=a.updated_at, ) def _check_device(db: Session, prefix: Prefix, device_id: int | None): if device_id is not None: d = get_or_404(db, Device, device_id, "Устройство") if d.organization_id != prefix.organization_id: raise HTTPException(422, "Устройство принадлежит другой организации") @router.get("/prefixes/{id}/addresses", response_model=s.AddressPage) def list_addresses( id: int, status: str = "", q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db), ): """status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей). Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети).""" p = get_or_404(db, Prefix, id, "Префикс") cap = capacity(str(p.prefix)) counts = dict(db.execute(select(Address.status, func.count()).where(Address.prefix_id == id).group_by(Address.status)).all()) stored = sum(counts.values()) summary = s.AddressSummary( assigned=counts.get(AddressStatus.assigned, 0), reserved=counts.get(AddressStatus.reserved, 0), deprecated=counts.get(AddressStatus.deprecated, 0), free=max(cap - stored, 0), capacity=cap, ) if status and status not in {"free", *(x.value for x in AddressStatus)}: raise HTTPException(422, "Неизвестный статус") net = ipaddress.ip_network(str(p.prefix)) def occupied() -> list[int]: return sorted(int(ipaddress.ip_address(s.ip_text(a))) for a in db.scalars(select(Address.address).where(Address.prefix_id == id))) def free_rows(ips: list[str]) -> list[s.AddressOut]: return [s.AddressOut(id=None, prefix_id=id, address=ip, status="free") for ip in ips] if status == "free": return s.AddressPage(items=free_rows(free_page(net, occupied(), offset, limit)), total=summary.free, summary=summary) flt = [Address.prefix_id == id] if status: flt.append(Address.status == AddressStatus(status)) if q: flt.append(or_(contains(func.host(Address.address), q), contains(Address.dns_name, q), contains(Address.description, q))) stmt = select(Address, Device.name).outerjoin(Device, Device.id == Address.device_id).where(*flt) mixed = not status and not q and cap <= FREE_LISTING_LIMIT # малая подсеть: занятые и свободные вперемешку (список ограничен размером подсети) if mixed: rows = [_addr_out(a, dn) for a, dn in db.execute(stmt.order_by(Address.address)).all()] rows = sorted(rows + free_rows(free_page(net, occupied(), 0, cap)), key=lambda r: ipaddress.ip_address(r.address)) return s.AddressPage(items=rows[offset:offset + limit], total=len(rows), summary=summary) total = count(db, select(Address.id).where(*flt)) page = db.execute(stmt.order_by(Address.address).limit(limit).offset(offset)).all() return s.AddressPage(items=[_addr_out(a, dn) for a, dn in page], total=total, summary=summary) @router.post("/prefixes/{id}/addresses", response_model=s.AddressOut, status_code=201) def create_address(id: int, body: s.AddressIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): p = get_or_404(db, Prefix, id, "Префикс") net, ip = ipaddress.ip_network(str(p.prefix)), ipaddress.ip_address(body.address) if ip not in net: raise HTTPException(422, f"Адрес {body.address} не принадлежит префиксу {p.prefix}") role = network_role(net, ip) if role: raise HTTPException(422, f"Адрес {body.address} — {'адрес сети' if role == 'network' else 'broadcast'} префикса {p.prefix}, назначить его нельзя") narrowest = _narrowest_prefix(db, p.vrf_id, body.address) if narrowest is not None and narrowest.id != p.id: # адрес хранится в самом узком префиксе VRF raise HTTPException(422, f"Адрес {body.address} принадлежит вложенному префиксу {narrowest.prefix}, назначьте его там") _check_device(db, p, body.device_id) a = Address(prefix_id=id, vrf_id=p.vrf_id, **body.model_dump()) db.add(a) flush(db, "Адрес уже есть в этом префиксе") audit(db, user, "address", a, "assigned" if a.status == AddressStatus.assigned else "created", body.address) commit(db, "Адрес уже есть в этом префиксе") db.refresh(a) return _addr_out(a) @router.post("/prefixes/{id}/addresses/next", response_model=s.AddressOut, status_code=201) def allocate_next( id: int, body: s.AddressUpdate | None = None, db: Session = Depends(get_db), user: User = Depends(admin_user) ): """Автоназначение первого свободного адреса; только для префиксов с флагом is_pool.""" p = db.scalar(select(Prefix).where(Prefix.id == id).with_for_update()) # параллельные запросы получают разные адреса if p is None: raise HTTPException(404, "Префикс не найден") if not p.is_pool: raise HTTPException(422, "Префикс не является пулом для автоназначения") ip = next_free_address(ipaddress.ip_network(str(p.prefix)), _busy_ranges(db, p)) # вложенные префиксы и адрес сети/broadcast пропускаются if ip is None: raise HTTPException(409, "В префиксе нет свободных адресов") data = body.model_dump(exclude_unset=True, exclude_none=True) if body else {} _check_device(db, p, data.get("device_id")) a = Address(prefix_id=id, vrf_id=p.vrf_id, address=ip, **data) db.add(a) flush(db, "Адрес уже занят, повторите запрос") audit(db, user, "address", a, "assigned", ip) commit(db, "Адрес уже занят, повторите запрос") db.refresh(a) return _addr_out(a) @router.patch("/addresses/{id}", response_model=s.AddressOut) def update_address(id: int, body: s.AddressUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)): a = get_or_404(db, Address, id, "Адрес") data = body.model_dump(exclude_unset=True) if data.get("dns_name"): s.AddressIn(address=s.ip_text(a.address), dns_name=data["dns_name"]) # валидация FQDN _check_device(db, db.get(Prefix, a.prefix_id), data.get("device_id")) changed = apply_update(a, data) audit(db, user, "address", a, "updated", s.ip_text(a.address), {k: str(v) for k, v in changed.items()}) commit(db) db.refresh(a) return _addr_out(a) @router.delete("/addresses/{id}", status_code=204) def delete_address(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): a = get_or_404(db, Address, id, "Адрес") audit(db, user, "address", a, "deleted", s.ip_text(a.address)) db.delete(a) commit(db)