Files
pvemanager/backend/app/api/ipam.py
markmorado 633f1a424c
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 7s
feat(appstore): кастомный порт при установке + мгновенная чистка инстанса
App Store:
- поле кастомного порта в мастере установки (переопределяет порт каталога)
- валидация диапазона порта 1..65535 (Pydantic Field + EngineError)
- APP_PORT в окружении синхронизируется с выбранным портом
- при удалении приложения инстанс сразу помечается deleted в кэше
  vm_instances (soft_delete_vm_instance), не дожидаясь фонового воркера

Также включены правки IPAM (привязка сетей к рабочей области, UI
WorkspaceSelect) и синхронизации каталога App Store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:20:28 +05:00

1050 lines
36 KiB
Python

"""
IPAM API Routes
IP Address Management endpoints for networks, pools, and allocations.
"""
import logging
import ipaddress as ipaddress_module
from typing import List, Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory, ProxmoxServer, User, VMInstance
from ..proxmox import ProxmoxClient
from ..schemas import (
IPAMNetworkCreate, IPAMNetworkUpdate, IPAMNetworkResponse, IPAMNetworkStats,
IPAMPoolCreate, IPAMPoolUpdate, IPAMPoolResponse,
IPAMAllocationCreate, IPAMAllocationUpdate, IPAMAllocationResponse,
IPAMHistoryResponse, IPAMAutoAllocateRequest
)
from ..ipam_service import IPAMService
from ..auth import get_current_user, PermissionChecker
logger = logging.getLogger(__name__)
router = APIRouter()
# ==================== Network API ====================
@router.get("/api/networks", response_model=List[IPAMNetworkResponse])
def get_networks(
request: Request,
is_active: Optional[bool] = None,
proxmox_server_id: Optional[int] = None,
workspace_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get all IPAM networks.
Filtered by the active workspace (X-Active-Workspace header): networks bound to
that workspace plus global networks (workspace_id IS NULL). Admin "All workspaces"
(header 0/absent) shows everything. An explicit ?workspace_id= query param overrides.
"""
from ..models import Workspace
query = db.query(IPAMNetwork)
if is_active is not None:
query = query.filter(IPAMNetwork.is_active == is_active)
if proxmox_server_id:
query = query.filter(IPAMNetwork.proxmox_server_id == proxmox_server_id)
# Resolve workspace scope: explicit query param wins, else the active-workspace header.
ws_filter = workspace_id
if ws_filter is None:
raw = request.headers.get("X-Active-Workspace")
if raw is not None:
try:
parsed = int(raw)
ws_filter = parsed if parsed != 0 else None
except ValueError:
ws_filter = None
if ws_filter:
# Networks of this workspace + global (unassigned) networks.
query = query.filter(
or_(IPAMNetwork.workspace_id == ws_filter, IPAMNetwork.workspace_id.is_(None))
)
networks = query.order_by(IPAMNetwork.name).all()
# Get server names for networks
server_ids = [n.proxmox_server_id for n in networks if n.proxmox_server_id]
servers = {}
if server_ids:
server_records = db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(server_ids)).all()
servers = {s.id: s.name for s in server_records}
# Get workspace names for networks
ws_ids = [n.workspace_id for n in networks if n.workspace_id]
workspaces = {}
if ws_ids:
ws_records = db.query(Workspace.id, Workspace.name).filter(Workspace.id.in_(ws_ids)).all()
workspaces = {w.id: w.name for w in ws_records}
# Add computed stats to each network
ipam = IPAMService(db)
result = []
for network in networks:
stats = ipam.get_network_stats(network.id)
network_dict = {
**network.__dict__,
'total_ips': stats.get('total_ips', 0),
'used_ips': stats.get('allocated_ips', 0) + stats.get('reserved_ips', 0),
'available_ips': stats.get('available_ips', 0),
'utilization_percent': stats.get('utilization_percent', 0),
'server_name': servers.get(network.proxmox_server_id) if network.proxmox_server_id else None,
'workspace_name': workspaces.get(network.workspace_id) if network.workspace_id else None,
}
result.append(network_dict)
return result
@router.get("/api/networks/{network_id}", response_model=IPAMNetworkResponse)
def get_network(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get a specific IPAM network"""
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
ipam = IPAMService(db)
stats = ipam.get_network_stats(network_id)
return {
**network.__dict__,
'total_ips': stats.get('total_ips', 0),
'used_ips': stats.get('allocated_ips', 0) + stats.get('reserved_ips', 0),
'available_ips': stats.get('available_ips', 0),
'utilization_percent': stats.get('utilization_percent', 0)
}
@router.get("/api/networks/{network_id}/stats", response_model=IPAMNetworkStats)
def get_network_stats(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get detailed statistics for a network"""
ipam = IPAMService(db)
stats = ipam.get_network_stats(network_id)
if not stats:
raise HTTPException(status_code=404, detail="Network not found")
return stats
@router.get("/api/networks/{network_id}/ip-map")
def get_network_ip_map(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get IP address map for visualization"""
ipam = IPAMService(db)
ip_map = ipam.get_network_ip_map(network_id)
if not ip_map:
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
return {"network_id": network_id, "ip_map": ip_map}
@router.post("/api/networks", response_model=IPAMNetworkResponse)
def create_network(
network_data: IPAMNetworkCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Create a new IPAM network"""
# Check for duplicate network CIDR
existing = db.query(IPAMNetwork).filter(IPAMNetwork.network == network_data.network).first()
if existing:
raise HTTPException(status_code=400, detail=f"Network {network_data.network} already exists")
network = IPAMNetwork(**network_data.model_dump())
db.add(network)
db.flush() # assign id before clearing other defaults
_enforce_single_default(db, network)
db.commit()
db.refresh(network)
logger.info(f"User {current_user.username} created network {network.name} ({network.network})")
return network
@router.put("/api/networks/{network_id}", response_model=IPAMNetworkResponse)
def update_network(
network_id: int,
network_data: IPAMNetworkUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Update an IPAM network"""
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
update_data = network_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(network, field, value)
_enforce_single_default(db, network)
db.commit()
db.refresh(network)
logger.info(f"User {current_user.username} updated network {network.name}")
return network
def _enforce_single_default(db: Session, network: IPAMNetwork) -> None:
"""Keep at most one default network per workspace: if this one is marked
default, clear the flag on siblings in the same workspace."""
if not network.is_default or network.workspace_id is None:
return
db.query(IPAMNetwork).filter(
IPAMNetwork.workspace_id == network.workspace_id,
IPAMNetwork.id != network.id,
IPAMNetwork.is_default == True, # noqa: E712
).update({IPAMNetwork.is_default: False}, synchronize_session=False)
@router.delete("/api/networks/{network_id}")
def delete_network(
network_id: int,
force: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Delete an IPAM network"""
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
# Check for existing allocations
alloc_count = db.query(IPAMAllocation).filter(IPAMAllocation.network_id == network_id).count()
if alloc_count > 0 and not force:
raise HTTPException(
status_code=400,
detail=f"Network has {alloc_count} allocations. Use force=true to delete anyway."
)
# Delete related data
db.query(IPAMAllocation).filter(IPAMAllocation.network_id == network_id).delete()
db.query(IPAMPool).filter(IPAMPool.network_id == network_id).delete()
db.query(IPAMHistory).filter(IPAMHistory.network_id == network_id).delete()
db.delete(network)
db.commit()
logger.info(f"User {current_user.username} deleted network {network.name}")
return {"message": f"Network {network.name} deleted"}
# ==================== Pool API ====================
@router.get("/api/pools", response_model=List[IPAMPoolResponse])
def get_pools(
network_id: Optional[int] = None,
is_active: Optional[bool] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get all IP pools"""
query = db.query(IPAMPool)
if network_id:
query = query.filter(IPAMPool.network_id == network_id)
if is_active is not None:
query = query.filter(IPAMPool.is_active == is_active)
pools = query.order_by(IPAMPool.network_id, IPAMPool.range_start).all()
# Add stats to each pool
ipam = IPAMService(db)
result = []
for pool in pools:
stats = ipam.get_pool_stats(pool.id)
pool_dict = {
**pool.__dict__,
'total_ips': stats.get('total_ips', 0),
'used_ips': stats.get('used_ips', 0),
'available_ips': stats.get('available_ips', 0)
}
result.append(pool_dict)
return result
@router.post("/api/pools", response_model=IPAMPoolResponse)
def create_pool(
pool_data: IPAMPoolCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Create a new IP pool"""
# Validate network exists
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == pool_data.network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
# Validate range
ipam = IPAMService(db)
is_valid, error = ipam.validate_pool_range(
pool_data.network_id,
pool_data.range_start,
pool_data.range_end
)
if not is_valid:
raise HTTPException(status_code=400, detail=error)
pool = IPAMPool(**pool_data.model_dump())
db.add(pool)
db.commit()
db.refresh(pool)
logger.info(f"User {current_user.username} created pool {pool.name} in network {network.name}")
return pool
@router.put("/api/pools/{pool_id}", response_model=IPAMPoolResponse)
def update_pool(
pool_id: int,
pool_data: IPAMPoolUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Update an IP pool"""
pool = db.query(IPAMPool).filter(IPAMPool.id == pool_id).first()
if not pool:
raise HTTPException(status_code=404, detail="Pool not found")
# Validate new range if provided
update_data = pool_data.model_dump(exclude_unset=True)
if 'range_start' in update_data or 'range_end' in update_data:
ipam = IPAMService(db)
is_valid, error = ipam.validate_pool_range(
pool.network_id,
update_data.get('range_start', pool.range_start),
update_data.get('range_end', pool.range_end),
exclude_pool_id=pool_id
)
if not is_valid:
raise HTTPException(status_code=400, detail=error)
for field, value in update_data.items():
setattr(pool, field, value)
db.commit()
db.refresh(pool)
logger.info(f"User {current_user.username} updated pool {pool.name}")
return pool
@router.delete("/api/pools/{pool_id}")
def delete_pool(
pool_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Delete an IP pool"""
pool = db.query(IPAMPool).filter(IPAMPool.id == pool_id).first()
if not pool:
raise HTTPException(status_code=404, detail="Pool not found")
# Update allocations to remove pool reference
db.query(IPAMAllocation).filter(IPAMAllocation.pool_id == pool_id).update({IPAMAllocation.pool_id: None})
db.delete(pool)
db.commit()
logger.info(f"User {current_user.username} deleted pool {pool.name}")
return {"message": f"Pool {pool.name} deleted"}
# ==================== Allocation API ====================
@router.get("/api/allocations", response_model=List[IPAMAllocationResponse])
def get_allocations(
network_id: Optional[int] = None,
pool_id: Optional[int] = None,
status: Optional[str] = None,
resource_type: Optional[str] = None,
search: Optional[str] = None,
limit: int = Query(default=100, le=1000),
offset: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get IP allocations with filtering"""
query = db.query(IPAMAllocation)
if network_id:
query = query.filter(IPAMAllocation.network_id == network_id)
if pool_id:
query = query.filter(IPAMAllocation.pool_id == pool_id)
if status:
query = query.filter(IPAMAllocation.status == status)
if resource_type:
query = query.filter(IPAMAllocation.resource_type == resource_type)
if search:
search_filter = f"%{search}%"
query = query.filter(
(IPAMAllocation.ip_address.like(search_filter)) |
(IPAMAllocation.resource_name.like(search_filter)) |
(IPAMAllocation.hostname.like(search_filter))
)
allocations = query.order_by(IPAMAllocation.ip_address).offset(offset).limit(limit).all()
# Resolve network/pool names with targeted queries (only IDs present in results)
network_ids = {a.network_id for a in allocations if a.network_id}
pool_ids = {a.pool_id for a in allocations if a.pool_id}
networks = {}
if network_ids:
networks = {
n.id: n.name for n in
db.query(IPAMNetwork.id, IPAMNetwork.name)
.filter(IPAMNetwork.id.in_(network_ids)).all()
}
pools = {}
if pool_ids:
pools = {
p.id: p.name for p in
db.query(IPAMPool.id, IPAMPool.name)
.filter(IPAMPool.id.in_(pool_ids)).all()
}
result = []
for alloc in allocations:
alloc_dict = {
**alloc.__dict__,
'network_name': networks.get(alloc.network_id),
'pool_name': pools.get(alloc.pool_id) if alloc.pool_id else None
}
result.append(alloc_dict)
return result
@router.get("/api/allocations/{allocation_id}", response_model=IPAMAllocationResponse)
def get_allocation(
allocation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get a specific allocation"""
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
if not allocation:
raise HTTPException(status_code=404, detail="Allocation not found")
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == allocation.network_id).first()
pool = db.query(IPAMPool).filter(IPAMPool.id == allocation.pool_id).first() if allocation.pool_id else None
return {
**allocation.__dict__,
'network_name': network.name if network else None,
'pool_name': pool.name if pool else None
}
@router.post("/api/allocations", response_model=IPAMAllocationResponse)
def create_allocation(
alloc_data: IPAMAllocationCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Manually create an IP allocation"""
ipam = IPAMService(db)
allocation, error = ipam.allocate_ip(
ip_address=alloc_data.ip_address,
network_id=alloc_data.network_id,
pool_id=alloc_data.pool_id,
resource_type=alloc_data.resource_type,
resource_id=alloc_data.resource_id,
resource_name=alloc_data.resource_name,
hostname=alloc_data.hostname,
fqdn=alloc_data.fqdn,
mac_address=alloc_data.mac_address,
proxmox_server_id=alloc_data.proxmox_server_id,
proxmox_vmid=alloc_data.proxmox_vmid,
proxmox_node=alloc_data.proxmox_node,
allocation_type=alloc_data.allocation_type,
status=alloc_data.status,
allocated_by=current_user.username,
notes=alloc_data.notes
)
if error:
raise HTTPException(status_code=400, detail=error)
return allocation
@router.post("/api/allocations/auto", response_model=IPAMAllocationResponse)
def auto_allocate_ip(
request: IPAMAutoAllocateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Automatically allocate the next available IP"""
ipam = IPAMService(db)
allocation, error = ipam.auto_allocate_ip(
network_id=request.network_id,
pool_id=request.pool_id,
resource_type=request.resource_type,
resource_id=request.resource_id,
resource_name=request.resource_name,
hostname=request.hostname,
allocated_by=current_user.username,
notes=request.notes
)
if error:
raise HTTPException(status_code=400, detail=error)
return allocation
@router.put("/api/allocations/{allocation_id}", response_model=IPAMAllocationResponse)
def update_allocation(
allocation_id: int,
alloc_data: IPAMAllocationUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Update an allocation"""
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
if not allocation:
raise HTTPException(status_code=404, detail="Allocation not found")
ipam = IPAMService(db)
updated, error = ipam.update_allocation(
allocation.ip_address,
updated_by=current_user.username,
**alloc_data.model_dump(exclude_unset=True)
)
if error:
raise HTTPException(status_code=400, detail=error)
return updated
@router.delete("/api/allocations/{allocation_id}")
def delete_allocation(
allocation_id: int,
reason: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Release/delete an IP allocation"""
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
if not allocation:
raise HTTPException(status_code=404, detail="Allocation not found")
ipam = IPAMService(db)
success, error = ipam.release_ip(
allocation.ip_address,
released_by=current_user.username,
reason=reason
)
if not success:
raise HTTPException(status_code=400, detail=error)
return {"message": f"IP {allocation.ip_address} released"}
@router.get("/api/allocations/next-available/{network_id}")
def get_next_available_ip(
network_id: int,
pool_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get the next available IP in a network (without allocating)"""
ipam = IPAMService(db)
ip = ipam.get_next_available_ip(network_id, pool_id)
if not ip:
raise HTTPException(status_code=404, detail="No available IP addresses")
return {"next_available_ip": ip}
# ==================== History API ====================
@router.get("/api/history", response_model=List[IPAMHistoryResponse])
def get_history(
network_id: Optional[int] = None,
ip_address: Optional[str] = None,
action: Optional[str] = None,
limit: int = Query(default=100, le=500),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get IPAM history"""
query = db.query(IPAMHistory)
if network_id:
query = query.filter(IPAMHistory.network_id == network_id)
if ip_address:
query = query.filter(IPAMHistory.ip_address == ip_address)
if action:
query = query.filter(IPAMHistory.action == action)
return query.order_by(IPAMHistory.performed_at.desc()).limit(limit).all()
@router.get("/api/history/ip/{ip_address}", response_model=List[IPAMHistoryResponse])
def get_ip_history(
ip_address: str,
limit: int = Query(default=50, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get history for a specific IP address"""
ipam = IPAMService(db)
return ipam.get_ip_history(ip_address, limit)
# ==================== Utility API ====================
@router.get("/api/conflicts")
def detect_conflicts(
network_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Detect IP address conflicts"""
ipam = IPAMService(db)
conflicts = ipam.detect_conflicts(network_id)
return {"conflicts": conflicts, "count": len(conflicts)}
@router.get("/api/summary")
def get_ipam_summary(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get overall IPAM summary"""
networks_count = db.query(IPAMNetwork).filter(IPAMNetwork.is_active == True).count()
pools_count = db.query(IPAMPool).filter(IPAMPool.is_active == True).count()
total_allocated = db.query(IPAMAllocation).filter(IPAMAllocation.status == 'allocated').count()
total_reserved = db.query(IPAMAllocation).filter(IPAMAllocation.status == 'reserved').count()
# Count by resource type
vm_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'vm').count()
lxc_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'lxc').count()
physical_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'physical').count()
# Recent activity
recent_allocations = db.query(IPAMHistory).filter(
IPAMHistory.action == 'allocated'
).order_by(IPAMHistory.performed_at.desc()).limit(5).all()
return {
'networks_count': networks_count,
'pools_count': pools_count,
'total_allocated': total_allocated,
'total_reserved': total_reserved,
'vm_count': vm_count,
'lxc_count': lxc_count,
'physical_count': physical_count,
'recent_allocations': [
{
'ip': h.ip_address,
'resource': h.resource_name,
'by': h.performed_by,
'at': h.performed_at.isoformat() if h.performed_at else None
} for h in recent_allocations
]
}
# ==================== Maintenance API ====================
@router.get("/api/orphans")
def get_orphan_allocations(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""
Get orphan IPAM allocations - IPs allocated to VMs that no longer exist.
"""
orphans = []
# 1. Allocations with proxmox_vmid but VM doesn't exist or is deleted
allocations_with_vmid = db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_vmid.isnot(None),
IPAMAllocation.proxmox_server_id.isnot(None),
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
for alloc in allocations_with_vmid:
vm = db.query(VMInstance).filter(
VMInstance.vmid == alloc.proxmox_vmid,
VMInstance.server_id == alloc.proxmox_server_id,
VMInstance.deleted_at.is_(None)
).first()
if not vm:
orphans.append({
'id': alloc.id,
'ip_address': alloc.ip_address,
'resource_name': alloc.resource_name,
'resource_type': alloc.resource_type,
'proxmox_vmid': alloc.proxmox_vmid,
'proxmox_server_id': alloc.proxmox_server_id,
'reason': 'VM/LXC not found or deleted'
})
# 2. Allocations to non-existent servers
allocations_with_server = db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_server_id.isnot(None),
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
server_ids = {a.proxmox_server_id for a in allocations_with_server}
existing_servers = {s.id for s in db.query(ProxmoxServer.id).filter(ProxmoxServer.id.in_(server_ids)).all()}
for alloc in allocations_with_server:
if alloc.proxmox_server_id not in existing_servers:
# Check if not already in orphans
if not any(o['id'] == alloc.id for o in orphans):
orphans.append({
'id': alloc.id,
'ip_address': alloc.ip_address,
'resource_name': alloc.resource_name,
'resource_type': alloc.resource_type,
'proxmox_vmid': alloc.proxmox_vmid,
'proxmox_server_id': alloc.proxmox_server_id,
'reason': f'Server {alloc.proxmox_server_id} not found'
})
return {
'orphans': orphans,
'count': len(orphans)
}
@router.post("/api/cleanup-orphans")
async def cleanup_orphan_allocations(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""
Clean up orphan IPAM allocations - release IPs for VMs that no longer exist.
"""
ipam = IPAMService(db)
released = []
errors = []
# Get orphans
orphans_result = get_orphan_allocations(db, current_user)
orphans = orphans_result['orphans']
for orphan in orphans:
try:
success, error = ipam.release_ip(
ip_address=orphan['ip_address'],
released_by=current_user.username,
reason=f"Orphan cleanup: {orphan['reason']}"
)
if success:
released.append(orphan['ip_address'])
logger.info(f"Released orphan IP {orphan['ip_address']} by {current_user.username}")
else:
errors.append({'ip': orphan['ip_address'], 'error': error})
except Exception as e:
errors.append({'ip': orphan['ip_address'], 'error': str(e)})
return {
'released': released,
'released_count': len(released),
'errors': errors,
'error_count': len(errors)
}
def _extract_primary_ipv4(interfaces: list, parsed_networks: list):
"""Extract first IPv4 that belongs to any IPAM network from interface list."""
for iface in interfaces:
for ip_info in iface.get('ips', []):
if ip_info.get('type') != 'ipv4':
continue
addr = ip_info.get('address', '')
try:
ip_obj = ipaddress_module.ip_address(addr)
except ValueError:
continue
if ip_obj.is_loopback or ip_obj.is_link_local:
continue
for net, net_obj in parsed_networks:
if ip_obj in net_obj:
return addr, net
return None, None
@router.get("/api/unlinked")
def get_unlinked_allocations(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""
Return VM instances that have no IPAM allocation linked to them (by vmid+server_id).
Only VMs whose cached ip_address falls within an active IPAM network are included.
"""
unlinked = []
# All live VMs
vms = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None)).all()
# Pre-load all linked (proxmox_server_id, proxmox_vmid) pairs for fast lookup
linked_pairs = set(
(a.proxmox_server_id, a.proxmox_vmid)
for a in db.query(IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid).filter(
IPAMAllocation.proxmox_vmid.isnot(None)
).all()
)
servers_map = {s.id: s for s in db.query(ProxmoxServer).all()}
# Parse active IPAM networks to filter by IP membership
ipam_nets = []
for net in db.query(IPAMNetwork).filter(IPAMNetwork.is_active == True).all():
try:
ipam_nets.append(ipaddress_module.ip_network(net.network, strict=False))
except ValueError:
pass
for vm in vms:
if (vm.server_id, vm.vmid) in linked_pairs:
continue
# Skip VMs with no IP or an IP outside all IPAM networks
if not vm.ip_address:
continue
try:
vm_ip = ipaddress_module.ip_address(vm.ip_address)
if not any(vm_ip in net for net in ipam_nets):
continue
except ValueError:
continue
server = servers_map.get(vm.server_id)
unlinked.append({
'id': None,
'ip_address': vm.ip_address,
'resource_name': vm.name,
'resource_type': vm.vm_type,
'can_link': True,
'suggested_vmid': vm.vmid,
'suggested_server_id': vm.server_id,
'suggested_server_name': server.name if server else None
})
return {
'unlinked': unlinked,
'count': len(unlinked),
'linkable_count': len(unlinked)
}
class LinkAllocationsRequest(BaseModel):
server_id: Optional[int] = None # If set, only process this server's VMs
network_id: Optional[int] = None # If set, only match against this IPAM network
@router.post("/api/link-allocations")
def link_allocations_to_vms(
body: LinkAllocationsRequest = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""
Scan Proxmox servers, get VM IP addresses via QEMU guest agent / LXC config,
and create/update IPAM allocations for each VM whose IP belongs to a known IPAM network.
When body.server_id is given: only process that server.
When body.network_id is given: only match against that specific network.
In auto mode (no params): each VM is matched only against networks whose
proxmox_server_id equals the VM's server (or networks without a server binding).
"""
if body is None:
body = LinkAllocationsRequest()
linked = []
not_found = []
service = IPAMService(db)
synced_by = current_user.username if hasattr(current_user, 'username') else 'system'
# Parse all active IPAM networks once (or just the requested one)
net_query = db.query(IPAMNetwork).filter(IPAMNetwork.is_active == True)
if body.network_id:
net_query = net_query.filter(IPAMNetwork.id == body.network_id)
networks = net_query.all()
parsed_networks = []
for net in networks:
try:
parsed_networks.append((net, ipaddress_module.ip_network(net.network, strict=False)))
except ValueError:
pass
if not parsed_networks:
return {'linked': [], 'linked_count': 0, 'not_found': [], 'not_found_count': 0,
'message': 'No active IPAM networks configured'}
# Pre-build set of already-linked (server_id, vmid) pairs
linked_pairs = set(
(a.proxmox_server_id, a.proxmox_vmid)
for a in db.query(IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid).filter(
IPAMAllocation.proxmox_vmid.isnot(None)
).all()
)
# All live VMs cached in DB (optionally filtered by server)
vm_query = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None))
if body.server_id:
vm_query = vm_query.filter(VMInstance.server_id == body.server_id)
vms = vm_query.all()
# Group VMs by server_id for efficient client reuse
vms_by_server: dict = {}
for vm in vms:
vms_by_server.setdefault(vm.server_id, []).append(vm)
for server_id, server_vms in vms_by_server.items():
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
continue
# Determine which networks apply for this server.
# If a specific network_id was requested, only use that (already filtered above).
# Otherwise restrict to networks that belong to this server OR are "global" (no server binding).
if body.network_id:
server_networks = parsed_networks # already filtered to the one network
else:
server_networks = [
(n, p) for n, p in parsed_networks
if n.proxmox_server_id == server_id or n.proxmox_server_id is None
]
if not server_networks:
logger.info(f"[IPAM link] Server {server.name} has no matching IPAM networks, skipping")
continue
try:
client = ProxmoxClient.from_server(server)
if not client.is_connected():
logger.warning(f"[IPAM link] Server {server.name} not reachable, skipping")
for vm in server_vms:
if (server_id, vm.vmid) not in linked_pairs:
not_found.append({'ip_address': '-', 'resource_name': vm.name,
'reason': 'server_unreachable'})
continue
except Exception as e:
logger.error(f"[IPAM link] Cannot build client for server {server.name}: {e}")
continue
for vm in server_vms:
# Skip already linked
if (server_id, vm.vmid) in linked_pairs:
continue
# For this VM, further filter networks by proxmox_node if set
# (e.g. network tagged to pve1 should not match VMs on pve2)
vm_networks = [
(n, p) for n, p in server_networks
if not n.proxmox_node or n.proxmox_node == vm.node
]
if not vm_networks:
not_found.append({'ip_address': '-', 'resource_name': vm.name,
'reason': 'no_network_for_node'})
continue
# Get interfaces from Proxmox
try:
if vm.vm_type == 'qemu':
interfaces = client.get_vm_interfaces(vm.node, vm.vmid)
else:
interfaces = client.get_container_interfaces(vm.node, vm.vmid)
except Exception as e:
logger.debug(f"[IPAM link] Cannot get interfaces for {vm.name}/{vm.vmid}: {e}")
interfaces = []
# Find first IPv4 in one of this VM's applicable IPAM networks
ip_found, matching_network = _extract_primary_ipv4(interfaces, vm_networks)
if not ip_found:
not_found.append({'ip_address': '-', 'resource_name': vm.name,
'reason': 'no_ip_in_ipam_network'})
continue
alloc, error = service.sync_from_proxmox_vm(
network_id=matching_network.id,
proxmox_server_id=server_id,
vmid=vm.vmid,
vm_name=vm.name,
vm_type=vm.vm_type,
ip_address=ip_found,
node=vm.node,
synced_by=synced_by
)
if alloc:
# Update the cached ip_address in vm_instances as well
vm.ip_address = ip_found
linked_pairs.add((server_id, vm.vmid)) # avoid double-linking
linked.append({
'ip_address': ip_found,
'resource_name': vm.name,
'vmid': vm.vmid,
'server_id': server_id
})
logger.info(f"[IPAM link] Linked {ip_found} -> VM {vm.vmid} ({vm.name}) on {server.name}")
else:
not_found.append({'ip_address': ip_found, 'resource_name': vm.name,
'reason': error or 'sync_error'})
logger.warning(f"[IPAM link] Failed {ip_found} for {vm.name}: {error}")
if linked:
try:
db.commit()
except Exception as e:
db.rollback()
logger.error(f"[IPAM link] Commit failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
return {
'linked': linked,
'linked_count': len(linked),
'not_found': not_found,
'not_found_count': len(not_found)
}