2 Commits

Author SHA1 Message Date
6796903712 feat(ipam): several IP addresses per instance
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m46s
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 3m7s
A guest was implicitly limited to a single allocation: lookups were
"(server, vmid) -> one row", release_ip_by_vmid freed one address, and
the link tool skipped guests that already had one. An instance can now
hold a primary address plus any number of aliases, managed from the
Network tab of the instance and visible on the IPAM pages.

Schema and service:
- ipam_allocations gains is_primary, assignment_kind, target_interface
  and apply_* columns; migration 43 marks existing rows as primary
- find_allocations_by_resource/set_primary; release_ip_by_vmid now frees
  every address of a deleted guest instead of just the first one
- next-available search may scan the whole subnet when a network has no
  auto-assign pool, skipping node and cached guest addresses so it never
  hands out an address that is already live

Applying to the guest (services/guest_ip_service.py):
- reuses script_engine, so LXC goes through pct exec and VMs through the
  guest agent; the shell script synchronises the whole alias list of an
  interface, which makes add, remove and retry the same code path
- persists via ifupdown, netplan, NetworkManager or sysconfig and never
  restarts guest networking; an unknown stack degrades to runtime_only
  and an unreachable guest keeps the allocation as pending

API and UI:
- /ipam/api/guests/{server_id}/{vmid}/addresses: list, add, apply,
  primary, delete; allocations list accepts guest filters
- instance list exposes ips[] alongside the primary ip
- addresses card on the Network tab, "+N" badge in the IP column,
  primary badge in the allocations table

Fixes found along the way:
- link-allocations registers every address of a guest, carries the MAC
  over, refuses to take an address away from another live guest and no
  longer matches allocations by resource name (which leaked an address
  between same-named guests on different servers)
- IPAM history no longer fails to serialise datetime values, which used
  to abort re-linking with a 500
2026-08-21 23:48:20 +05:00
c9d8e8207f fix(proxmox): read LXC addresses from netN and live interfaces
get_container_interfaces() looked for ipconfigN keys, which exist only on
QEMU cloud-init guests. LXC keeps the address inside the netN string, so
the method never returned anything and containers showed no IP at all.

Parse netN with the existing net_parser, fall back to
/nodes/{node}/lxc/{vmid}/interfaces for DHCP guests (skipped when the
config already carries a static address or the container is stopped), and
validate addresses so loopback, link-local and the literal "dhcp" are
dropped. IPv6 from ip6/inet6 is reported as well.
2026-08-21 23:47:45 +05:00
25 changed files with 3044 additions and 191 deletions

View File

@@ -5,7 +5,7 @@ IP Address Management endpoints for networks, pools, and allocations.
import logging
import ipaddress as ipaddress_module
from typing import List, Optional
from typing import List, NamedTuple, Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import or_
@@ -21,6 +21,8 @@ from ..schemas import (
IPAMHistoryResponse, IPAMAutoAllocateRequest
)
from ..ipam_service import IPAMService
from ..services import guest_ip_service
from .proxmox._helpers import require_vm_access
from ..auth import get_current_user, PermissionChecker
logger = logging.getLogger(__name__)
@@ -382,6 +384,8 @@ def get_allocations(
pool_id: Optional[int] = None,
status: Optional[str] = None,
resource_type: Optional[str] = None,
proxmox_server_id: Optional[int] = None,
proxmox_vmid: Optional[int] = None,
search: Optional[str] = None,
limit: int = Query(default=100, le=1000),
offset: int = 0,
@@ -402,6 +406,13 @@ def get_allocations(
if resource_type:
query = query.filter(IPAMAllocation.resource_type == resource_type)
# Фильтр по конкретному гостю: у него может быть несколько адресов
if proxmox_server_id is not None:
query = query.filter(IPAMAllocation.proxmox_server_id == proxmox_server_id)
if proxmox_vmid is not None:
query = query.filter(IPAMAllocation.proxmox_vmid == proxmox_vmid)
if search:
search_filter = f"%{search}%"
@@ -789,13 +800,29 @@ async def cleanup_orphan_allocations(
}
def _extract_primary_ipv4(interfaces: list, parsed_networks: list):
"""Extract first IPv4 that belongs to any IPAM network from interface list."""
class DiscoveredAddress(NamedTuple):
"""Адрес гостя, найденный в одной из известных IPAM-сетей."""
ip: str
network: object # IPAMNetwork
mac: Optional[str] # MAC интерфейса, если он известен
interface: Optional[str] # имя интерфейса гостя (eth0, ens18, ...)
def _extract_ipv4s(interfaces: list, parsed_networks: list) -> List[DiscoveredAddress]:
"""Все IPv4 гостя, попадающие в известные IPAM-сети, в порядке обнаружения.
Гость может держать несколько адресов, поэтому возвращается список, а не
первое совпадение. Loopback и link-local отбрасываются.
"""
found: List[DiscoveredAddress] = []
seen = set()
for iface in interfaces:
for ip_info in iface.get('ips', []):
if ip_info.get('type') != 'ipv4':
continue
addr = ip_info.get('address', '')
if not addr or addr in seen:
continue
try:
ip_obj = ipaddress_module.ip_address(addr)
except ValueError:
@@ -804,8 +831,15 @@ def _extract_primary_ipv4(interfaces: list, parsed_networks: list):
continue
for net, net_obj in parsed_networks:
if ip_obj in net_obj:
return addr, net
return None, None
seen.add(addr)
found.append(DiscoveredAddress(
ip=addr,
network=net,
mac=(iface.get('hardware_address') or '').upper() or None,
interface=iface.get('name') or None,
))
break
return found
@router.get("/api/unlinked")
@@ -814,21 +848,26 @@ def get_unlinked_allocations(
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.
Return VM instances whose current address is not registered in IPAM.
У гостя может быть несколько адресов, поэтому «связан» определяется не по
наличию хоть какой-то аллокации, а по конкретному адресу: если в кэше
инстанса стоит IP, которого нет среди его аллокаций, гость показывается
как несвязанный. Учитываются только адреса из активных IPAM-сетей.
"""
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()
)
# (server_id, vmid) -> множество уже учтённых адресов гостя
linked_ips = {}
for alloc in db.query(
IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid, IPAMAllocation.ip_address
).filter(IPAMAllocation.proxmox_vmid.isnot(None)).all():
linked_ips.setdefault((alloc.proxmox_server_id, alloc.proxmox_vmid), set()).add(
alloc.ip_address
)
servers_map = {s.id: s for s in db.query(ProxmoxServer).all()}
@@ -841,12 +880,11 @@ def get_unlinked_allocations(
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
if vm.ip_address in linked_ips.get((vm.server_id, vm.vmid), ()):
continue
try:
vm_ip = ipaddress_module.ip_address(vm.ip_address)
if not any(vm_ip in net for net in ipam_nets):
@@ -972,9 +1010,9 @@ def link_allocations_to_vms(
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)
@@ -997,40 +1035,59 @@ def link_allocations_to_vms(
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)
# Все IPv4 гостя, попадающие в его IPAM-сети
discovered = _extract_ipv4s(interfaces, vm_networks)
if not ip_found:
if not discovered:
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
)
already_linked = {
a.ip_address for a in
service.find_allocations_by_resource(server_id, vm.vmid)
}
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}")
for found in discovered:
if found.ip in already_linked:
continue # этот адрес гостя уже учтён
alloc, error = service.sync_from_proxmox_vm(
network_id=found.network.id,
proxmox_server_id=server_id,
vmid=vm.vmid,
vm_name=vm.name,
vm_type=vm.vm_type,
ip_address=found.ip,
node=vm.node,
mac_address=found.mac,
synced_by=synced_by,
target_interface=found.interface,
# Адреса сверх первого — алиасы: их поднимают внутри
# гостя, а не через net0/ipconfig0.
assignment_kind='alias',
)
if alloc:
already_linked.add(found.ip)
linked_pairs.add((server_id, vm.vmid))
# Кэш инстанса показывает основной адрес
if alloc.is_primary:
vm.ip_address = found.ip
linked.append({
'ip_address': found.ip,
'resource_name': vm.name,
'vmid': vm.vmid,
'server_id': server_id,
'is_primary': bool(alloc.is_primary),
})
logger.info(
f"[IPAM link] Linked {found.ip} -> VM {vm.vmid} ({vm.name}) on {server.name}"
)
else:
not_found.append({'ip_address': found.ip, 'resource_name': vm.name,
'reason': error or 'sync_error'})
logger.warning(f"[IPAM link] Failed {found.ip} for {vm.name}: {error}")
if linked:
try:
@@ -1047,3 +1104,261 @@ def link_allocations_to_vms(
'not_found_count': len(not_found)
}
# ==================== Guest addresses (несколько IP на инстанс) ====================
#
# Основной адрес гостя задаётся конфигом Proxmox (net0 / ipconfig0) и здесь
# только помечается флагом. Дополнительные навешиваются алиасами внутри гостя
# силами services/guest_ip_service.py.
class GuestAddressCreate(BaseModel):
network_id: int
pool_id: Optional[int] = None
ip_address: Optional[str] = None # пусто -> следующий свободный в сети/пуле
target_interface: Optional[str] = None # пусто -> интерфейс основного адреса
make_primary: bool = False
hostname: Optional[str] = None
notes: Optional[str] = None
def _allocation_payload(db: Session, alloc: IPAMAllocation) -> dict:
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == alloc.network_id).first()
prefix = None
if network and network.network and '/' in network.network:
prefix = network.network.split('/')[1]
return {
'id': alloc.id,
'ip_address': alloc.ip_address,
'prefix': prefix,
'network_id': alloc.network_id,
'network_name': network.name if network else None,
'gateway': network.gateway if network else None,
'mac_address': alloc.mac_address,
'status': alloc.status,
'is_primary': bool(alloc.is_primary),
'assignment_kind': alloc.assignment_kind,
'target_interface': alloc.target_interface,
'apply_status': alloc.apply_status,
'apply_error': alloc.apply_error,
'applied_at': alloc.applied_at,
'hostname': alloc.hostname,
'notes': alloc.notes,
}
def _guest_or_404(db: Session, server_id: int, vmid: int) -> VMInstance:
guest = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None)
).first()
if not guest:
raise HTTPException(status_code=404, detail="Instance not found")
return guest
def _guest_allocation_or_404(db: Session, server_id: int, vmid: int,
allocation_id: int) -> IPAMAllocation:
allocation = db.query(IPAMAllocation).filter(
IPAMAllocation.id == allocation_id,
IPAMAllocation.proxmox_server_id == server_id,
IPAMAllocation.proxmox_vmid == vmid
).first()
if not allocation:
raise HTTPException(status_code=404, detail="Address not found for this instance")
return allocation
def _refresh_primary_cache(db: Session, service: IPAMService, guest: VMInstance) -> None:
"""Кэш инстанса показывает основной адрес — он же в колонке IP списка."""
primary = service.find_allocation_by_resource(guest.server_id, guest.vmid)
if primary:
guest.ip_address = primary.ip_address
db.commit()
@router.get("/api/guests/{server_id}/{vmid}/addresses")
def list_guest_addresses(
server_id: int,
vmid: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Все IP-адреса инстанса: основной первым, затем алиасы."""
require_vm_access(db, current_user, server_id, vmid)
service = IPAMService(db)
rows = service.find_allocations_by_resource(server_id, vmid)
return {
'addresses': [_allocation_payload(db, a) for a in rows],
'count': len(rows),
}
@router.post("/api/guests/{server_id}/{vmid}/addresses")
def add_guest_address(
server_id: int,
vmid: int,
body: GuestAddressCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Выдать инстансу дополнительный адрес и навесить его на интерфейс.
Адрес резервируется в IPAM в любом случае: если гость недоступен, запись
остаётся со статусом pending, и её можно применить позже.
"""
require_vm_access(db, current_user, server_id, vmid)
guest = _guest_or_404(db, server_id, vmid)
service = IPAMService(db)
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == body.network_id).first()
if not network:
raise HTTPException(status_code=404, detail="IPAM network not found")
ip_address = body.ip_address
if not ip_address:
# Доп. адрес выдаётся точечно, поэтому при отсутствии auto-assign пула
# ищем свободный по всей подсети, а не отказываем.
ip_address = service.get_next_available_ip(
body.network_id, body.pool_id, allow_network_scan=True
)
if not ip_address:
raise HTTPException(status_code=409, detail="No free IP left in this network/pool")
interface = body.target_interface
if not interface:
primary = service.find_allocation_by_resource(server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
try:
client = ProxmoxClient.from_server(server)
interface = guest_ip_service.resolve_interface(
client, guest.vm_type, guest.node, vmid,
primary_ip=primary.ip_address if primary else None,
)
except Exception as e:
logger.debug(f"[guest-ip] fallback interface for {vmid}: {e}")
interface = guest_ip_service.DEFAULT_INTERFACE
allocation, error = service.allocate_ip(
ip_address=ip_address,
network_id=body.network_id,
pool_id=body.pool_id,
resource_type=guest.vm_type,
resource_name=guest.name,
hostname=body.hostname,
proxmox_server_id=server_id,
proxmox_vmid=vmid,
proxmox_node=guest.node,
allocated_by=current_user.username,
notes=body.notes,
assignment_kind="alias",
target_interface=interface,
apply_status=guest_ip_service.PENDING,
)
if error:
raise HTTPException(status_code=400, detail=error)
apply_result = guest_ip_service.apply_address(db, allocation)
if body.make_primary:
service.set_primary(server_id, vmid, allocation.id, changed_by=current_user.username)
_refresh_primary_cache(db, service, guest)
db.refresh(allocation)
return {
'address': _allocation_payload(db, allocation),
'applied': apply_result.success,
'apply_status': apply_result.status,
'persist': apply_result.persist,
'error': apply_result.error,
}
@router.post("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}/apply")
def apply_guest_address(
server_id: int,
vmid: int,
allocation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Повторить применение адреса — например, после включения гостя."""
require_vm_access(db, current_user, server_id, vmid)
allocation = _guest_allocation_or_404(db, server_id, vmid, allocation_id)
result = guest_ip_service.apply_address(db, allocation)
db.refresh(allocation)
return {
'address': _allocation_payload(db, allocation),
'applied': result.success,
'apply_status': result.status,
'persist': result.persist,
'error': result.error,
}
@router.post("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}/primary")
def set_guest_primary_address(
server_id: int,
vmid: int,
allocation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Пометить адрес основным — он показывается в колонке IP списка инстансов."""
require_vm_access(db, current_user, server_id, vmid)
guest = _guest_or_404(db, server_id, vmid)
_guest_allocation_or_404(db, server_id, vmid, allocation_id)
service = IPAMService(db)
allocation, error = service.set_primary(
server_id, vmid, allocation_id, changed_by=current_user.username
)
if error:
raise HTTPException(status_code=400, detail=error)
_refresh_primary_cache(db, service, guest)
return {'address': _allocation_payload(db, allocation)}
@router.delete("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}")
def remove_guest_address(
server_id: int,
vmid: int,
allocation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Снять адрес с гостя и освободить его в IPAM."""
require_vm_access(db, current_user, server_id, vmid)
guest = _guest_or_404(db, server_id, vmid)
allocation = _guest_allocation_or_404(db, server_id, vmid, allocation_id)
if allocation.assignment_kind != "alias":
raise HTTPException(
status_code=400,
detail="Primary address comes from the NIC config — change it on the Network tab",
)
ip_address = allocation.ip_address
interface = allocation.target_interface or guest_ip_service.DEFAULT_INTERFACE
service = IPAMService(db)
released, error = service.release_ip(
ip_address, released_by=current_user.username,
reason=f"Removed from {guest.name} ({vmid})",
)
if not released:
raise HTTPException(status_code=400, detail=error or "Failed to release IP")
# Аллокации уже нет — синхронизация снимет адрес с интерфейса гостя.
result = guest_ip_service.sync_interface(db, server_id, vmid, interface)
_refresh_primary_cache(db, service, guest)
return {
'released': ip_address,
'removed_from_guest': result.success,
'error': result.error,
}

View File

@@ -164,20 +164,26 @@ def get_all_virtual_machines(
ipam_networks = db.query(IPAMNetwork).all()
network_map = {n.id: n for n in ipam_networks}
# Build IPAM lookups
# Build IPAM lookups.
# Только по идентификатору гостя: сопоставление по имени давало чужой адрес
# одноимённым гостям на разных серверах (два разных "wireguard").
# У гостя может быть несколько адресов: в ipam_by_vmid кладём основной
# (он идёт в колонку IP), в ipam_all_by_vmid — весь список для поля ips.
ipam_by_vmid = {}
ipam_by_name = {}
ipam_all_by_vmid = {}
for alloc in ipam_allocations:
if alloc.proxmox_server_id and alloc.proxmox_vmid:
ipam_by_vmid[(alloc.proxmox_server_id, alloc.proxmox_vmid)] = alloc
key = (alloc.proxmox_server_id, alloc.proxmox_vmid)
ipam_all_by_vmid.setdefault(key, []).append(alloc)
current = ipam_by_vmid.get(key)
# Основной — тот, у кого стоит флаг; иначе самая ранняя запись
# (аллокации старше миграции multi-IP флага не имеют).
if current is None or (alloc.is_primary and not current.is_primary):
ipam_by_vmid[key] = alloc
elif alloc.resource_id:
ipam_by_vmid[(None, alloc.resource_id)] = alloc
if alloc.hostname:
ipam_by_name[alloc.hostname.lower()] = alloc
if alloc.resource_name:
ipam_by_name[alloc.resource_name.lower()] = alloc
ipam_by_vmid.setdefault((None, alloc.resource_id), alloc)
# Build base query for cached VMs (not deleted, not templates)
# joinedload(owner) avoids an N+1 query when rendering the owner column.
query = db.query(VMInstance).options(joinedload(VMInstance.owner)).filter(
@@ -267,14 +273,29 @@ def get_all_virtual_machines(
# Get IP from IPAM or cache
ipam_alloc = (
ipam_by_vmid.get((vm.server_id, vm.vmid)) or
ipam_by_vmid.get((None, vm.vmid)) or
ipam_by_name.get(vm.name.lower())
ipam_by_vmid.get((vm.server_id, vm.vmid)) or
ipam_by_vmid.get((None, vm.vmid))
)
ip_address = ipam_alloc.ip_address if ipam_alloc else (vm.ip_address or "")
ip_hostname = ipam_alloc.hostname if ipam_alloc else ""
owner = ipam_alloc.allocated_by if ipam_alloc else ""
# Полный список адресов гостя: основной первым, дальше алиасы
guest_allocs = sorted(
ipam_all_by_vmid.get((vm.server_id, vm.vmid), []),
key=lambda a: (not bool(a.is_primary), a.id),
)
ip_list = [
{
"ip": a.ip_address,
"is_primary": bool(a.is_primary),
"assignment_kind": a.assignment_kind,
"target_interface": a.target_interface,
"apply_status": a.apply_status,
}
for a in guest_allocs
]
# Get network name from IPAM
ip_network_name = ""
@@ -312,8 +333,11 @@ def get_all_virtual_machines(
"cores": vm.cores or 0,
"memory": vm.memory or 0,
"disk": live.get('disk') or 0,
# ip/ip_address — основной адрес (обратная совместимость),
# ips — все адреса гостя
"ip": ip_address,
"ip_address": ip_address,
"ips": ip_list,
"ip_hostname": ip_network_name,
"os": os_template,
"os_template": os_template,

View File

@@ -10,7 +10,9 @@ from datetime import datetime, timezone
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, text
from .models import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory
from .models import (
IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory, VMInstance, ProxmoxServer
)
def utcnow() -> datetime:
@@ -18,6 +20,21 @@ def utcnow() -> datetime:
return datetime.now(timezone.utc)
def _json_safe(value: Any) -> Any:
"""Привести значение к JSON-сериализуемому виду для колонок истории.
old_value/new_value — JSON-колонки, а в diff попадают и datetime-поля
(last_seen, expires_at): без конвертации INSERT падает на json.dumps.
"""
if isinstance(value, dict):
return {key: _json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
if isinstance(value, datetime):
return value.isoformat()
return value
logger = logging.getLogger(__name__)
@@ -142,7 +159,8 @@ class IPAMService:
self,
network_id: int,
pool_id: Optional[int] = None,
prefer_sequential: bool = True
prefer_sequential: bool = True,
allow_network_scan: bool = False
) -> Optional[str]:
"""
Get the next available IP address from a network or specific pool.
@@ -151,6 +169,9 @@ class IPAMService:
network_id: Network ID
pool_id: Optional specific pool ID
prefer_sequential: If True, prefer sequential IPs; if False, can use any gap
allow_network_scan: искать по всей подсети, когда auto-assign пулов
нет. Нужно там, где адрес выдаётся точечно (доп. IP инстанса),
а не по политике пула.
Returns:
Available IP address string or None if no IPs available
@@ -179,7 +200,7 @@ class IPAMService:
IPAMPool.auto_assign == True
).order_by(IPAMPool.id).all()
if not pools:
if not pools and not allow_network_scan:
logger.warning(f"No active auto-assign pools found for network {network_id}")
return None
@@ -213,7 +234,34 @@ class IPAMService:
except ValueError as e:
logger.error(f"Invalid IP range in pool {pool.id}: {e}")
continue
# Пулов нет (или в них всё занято) — при явном разрешении идём по всей
# подсети: адрес и сеть/broadcast исключает сам ip_network.hosts().
if allow_network_scan and not pool_id:
# Вне пула в подсети живут адреса, которых нет в IPAM: сами ноды
# Proxmox и гости, чей адрес известен панели только из кэша.
# Без этого скан спокойно предложил бы адрес работающей ноды.
known = set(occupied)
known.update(
row[0] for row in self.db.query(VMInstance.ip_address).filter(
VMInstance.ip_address.isnot(None),
VMInstance.deleted_at.is_(None)
).all() if row[0]
)
known.update(
row[0] for row in self.db.query(ProxmoxServer.ip_address).filter(
ProxmoxServer.ip_address.isnot(None)
).all() if row[0]
)
try:
for ip_obj in ipaddress.ip_network(network.network, strict=False).hosts():
ip = str(ip_obj)
if ip not in known:
logger.info(f"Found available IP: {ip} in network {network.network}")
return ip
except ValueError as e:
logger.error(f"Invalid network {network.network}: {e}")
logger.warning(f"No available IPs found in network {network_id}")
return None
@@ -234,7 +282,11 @@ class IPAMService:
allocation_type: str = "static",
status: str = "allocated",
allocated_by: Optional[str] = None,
notes: Optional[str] = None
notes: Optional[str] = None,
is_primary: bool = False,
assignment_kind: str = "primary",
target_interface: Optional[str] = None,
apply_status: Optional[str] = None
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
"""
Allocate an IP address.
@@ -310,7 +362,11 @@ class IPAMService:
hostname=hostname,
fqdn=fqdn,
allocated_by=allocated_by,
notes=notes
notes=notes,
is_primary=is_primary,
assignment_kind=assignment_kind,
target_interface=target_interface,
apply_status=apply_status
)
self.db.add(allocation)
@@ -436,46 +492,51 @@ class IPAMService:
reason: Optional[str] = None
) -> Tuple[bool, Optional[str]]:
"""
Release IP allocation by Proxmox VM/container ID.
Used when VM/container is deleted.
Args:
proxmox_server_id: Proxmox server ID
proxmox_vmid: VM/container ID
released_by: Username performing the release
reason: Reason for release
Release every IP allocation of a Proxmox VM/container.
Used when the VM/container is deleted.
У гостя может быть несколько адресов (основной + алиасы), поэтому
освобождаются все: иначе после удаления гостя часть адресов навсегда
оставалась бы занятой.
Returns:
Tuple of (success, error message or released IP)
Tuple of (success, comma-separated released IPs or error message)
"""
# First try to find by proxmox_server_id and proxmox_vmid
allocation = self.find_allocation_by_resource(proxmox_server_id, proxmox_vmid)
allocations = self.find_allocations_by_resource(proxmox_server_id, proxmox_vmid)
# Fallback: search by resource_id if proxmox fields are not set.
# ВАЖНО: только среди записей БЕЗ proxmox_server_id — иначе удаление
# VM с vmid=100 на сервере A освобождало IP виртуалки с тем же vmid
# на сервере B (записи с заполненным server_id находит основной поиск).
if not allocation:
allocation = self.db.query(IPAMAllocation).filter(
if not allocations:
allocations = self.db.query(IPAMAllocation).filter(
IPAMAllocation.resource_id == proxmox_vmid,
IPAMAllocation.proxmox_server_id.is_(None),
IPAMAllocation.status.in_(['allocated', 'reserved'])
).first()
if allocation:
logger.debug(f"Found allocation by resource_id={proxmox_vmid} (legacy)")
if not allocation:
).all()
if allocations:
logger.debug(f"Found allocation(s) by resource_id={proxmox_vmid} (legacy)")
if not allocations:
logger.debug(f"No IPAM allocation found for server {proxmox_server_id}, vmid {proxmox_vmid}")
return False, None # Not an error, just no allocation to release
ip_address = allocation.ip_address
success, error = self.release_ip(ip_address, released_by, reason)
if success:
logger.info(f"Auto-released IP {ip_address} for deleted VM/container {proxmox_vmid}")
return True, ip_address
return False, error
released, errors = [], []
for allocation in allocations:
ip_address = allocation.ip_address
success, error = self.release_ip(ip_address, released_by, reason)
if success:
released.append(ip_address)
else:
errors.append(error or ip_address)
if released:
logger.info(
f"Auto-released IP(s) {', '.join(released)} for deleted VM/container {proxmox_vmid}"
)
return True, ", ".join(released)
return False, "; ".join(errors) if errors else None
def update_allocation(
self,
@@ -537,17 +598,65 @@ class IPAMService:
return allocation, None
def find_allocations_by_resource(
self,
proxmox_server_id: int,
proxmox_vmid: int
) -> List[IPAMAllocation]:
"""Все активные адреса гостя: основной первым, дальше по дате выдачи."""
rows = self.db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_server_id == proxmox_server_id,
IPAMAllocation.proxmox_vmid == proxmox_vmid,
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
return sorted(rows, key=lambda a: (not bool(a.is_primary), a.id))
def find_allocation_by_resource(
self,
proxmox_server_id: int,
proxmox_vmid: int
) -> Optional[IPAMAllocation]:
"""Find active allocation by Proxmox VM/container"""
return self.db.query(IPAMAllocation).filter(
"""Основной адрес гостя (у гостя их может быть несколько).
Если флаг is_primary нигде не проставлен — например, записи старше
миграции multi-IP, — берём самую раннюю аллокацию.
"""
rows = self.find_allocations_by_resource(proxmox_server_id, proxmox_vmid)
return rows[0] if rows else None
def set_primary(
self,
proxmox_server_id: int,
proxmox_vmid: int,
allocation_id: int,
changed_by: Optional[str] = None
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
"""Сделать адрес основным, сняв флаг с прежнего. Основной ровно один."""
rows = self.db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_server_id == proxmox_server_id,
IPAMAllocation.proxmox_vmid == proxmox_vmid,
IPAMAllocation.status.in_(['allocated', 'reserved'])
).first()
IPAMAllocation.proxmox_vmid == proxmox_vmid
).all()
target = next((a for a in rows if a.id == allocation_id), None)
if not target:
return None, "Allocation does not belong to this guest"
for row in rows:
row.is_primary = (row.id == allocation_id)
self._log_history(
ip_address=target.ip_address,
network_id=target.network_id,
action="modified",
new_value={'is_primary': True},
resource_type=target.resource_type,
resource_id=target.resource_id,
resource_name=target.resource_name,
performed_by=changed_by,
notes="Marked as primary address"
)
self.db.commit()
self.db.refresh(target)
return target, None
def release_all_by_server(self, proxmox_server_id: int, released_by: str = None) -> int:
"""
@@ -675,7 +784,17 @@ class IPAMService:
return True, None
# ==================== Sync & Scan Operations ====================
def _guest_exists(self, server_id: Optional[int], vmid: Optional[int]) -> bool:
"""Существует ли ещё гость, за которым числится аллокация."""
if server_id is None or vmid is None:
return False
return self.db.query(VMInstance.id).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None)
).first() is not None
def sync_from_proxmox_vm(
self,
network_id: int,
@@ -686,18 +805,36 @@ class IPAMService:
ip_address: str,
node: str,
mac_address: Optional[str] = None,
synced_by: str = "system"
synced_by: str = "system",
target_interface: Optional[str] = None,
assignment_kind: str = "primary"
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
"""
Sync/create allocation for a Proxmox VM.
Called when discovering VMs from Proxmox.
Гость может иметь несколько адресов: первый заведённый становится
основным, последующие — обычными записями того же гостя.
"""
# Check if already allocated
existing = self.db.query(IPAMAllocation).filter(
IPAMAllocation.ip_address == ip_address
).first()
if existing:
# Два живых гостя с одним адресом (например, устаревший ip= в конфиге
# остановленного контейнера) не должны молча отбирать аллокацию друг
# у друга: владелец сохраняется, конфликт возвращается вызывающему.
belongs_to_other = (
existing.proxmox_vmid is not None
and (existing.proxmox_server_id, existing.proxmox_vmid) != (proxmox_server_id, vmid)
)
if belongs_to_other and self._guest_exists(existing.proxmox_server_id, existing.proxmox_vmid):
return None, (
f"IP {ip_address} is already linked to "
f"{existing.resource_name or 'unknown'} (VMID {existing.proxmox_vmid}) — "
f"duplicate address, resolve it in Proxmox first"
)
# Update existing allocation
if existing.proxmox_vmid != vmid or existing.proxmox_server_id != proxmox_server_id:
return self.update_allocation(
@@ -725,7 +862,14 @@ class IPAMService:
self.db.commit()
return existing, None
# Create new allocation
# Create new allocation. Основным делаем первый адрес гостя —
# именно он попадёт в колонку IP списка инстансов.
has_primary = self.db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_server_id == proxmox_server_id,
IPAMAllocation.proxmox_vmid == vmid,
IPAMAllocation.is_primary.is_(True)
).first() is not None
return self.allocate_ip(
ip_address=ip_address,
network_id=network_id,
@@ -736,7 +880,10 @@ class IPAMService:
proxmox_node=node,
mac_address=mac_address,
allocated_by=synced_by,
notes="Auto-synced from Proxmox"
notes="Auto-synced from Proxmox",
is_primary=not has_primary,
assignment_kind="primary" if not has_primary else assignment_kind,
target_interface=target_interface
)
def detect_conflicts(self, network_id: Optional[int] = None) -> List[Dict[str, Any]]:
@@ -801,8 +948,8 @@ class IPAMService:
ip_address=ip_address,
network_id=network_id,
action=action,
old_value=old_value,
new_value=new_value,
old_value=_json_safe(old_value),
new_value=_json_safe(new_value),
resource_type=resource_type,
resource_id=resource_id,
resource_name=resource_name,

View File

@@ -87,6 +87,21 @@ class IPAMAllocation(Base):
status = Column(String(20), default="allocated", nullable=False)
allocation_type = Column(String(20), default="static", nullable=False)
# Гость может держать несколько адресов: ровно один из них основной
# (живёт в net0/ipconfig0 и показывается в колонке IP), остальные
# навешиваются алиасами на интерфейс уже внутри гостя.
is_primary = Column(Boolean, default=False, nullable=False)
assignment_kind = Column(String(10), default="primary", nullable=False) # primary | alias
target_interface = Column(String(20), nullable=True) # eth0, ens18, ...
# Состояние применения алиаса к живому гостю.
# applied — поднят и закреплён в конфиге ОС; runtime_only — поднят, но
# стек не распознан и ребут его не переживёт; pending — гость был
# недоступен; failed — скрипт вернул ошибку.
apply_status = Column(String(20), nullable=True)
apply_error = Column(Text, nullable=True)
applied_at = Column(DateTime(timezone=True), nullable=True)
hostname = Column(String(255), nullable=True, index=True)
fqdn = Column(String(255), nullable=True)
@@ -106,6 +121,7 @@ class IPAMAllocation(Base):
Index('idx_ipam_alloc_network', 'network_id'),
Index('idx_ipam_alloc_resource', 'resource_type', 'resource_id'),
Index('idx_ipam_alloc_proxmox', 'proxmox_server_id', 'proxmox_vmid'),
Index('idx_ipam_alloc_primary', 'proxmox_server_id', 'proxmox_vmid', 'is_primary'),
Index('idx_ipam_alloc_status', 'status'),
)

View File

@@ -1,4 +1,5 @@
import time
import ipaddress
import urllib3
import asyncio
from concurrent.futures import ThreadPoolExecutor
@@ -10,6 +11,7 @@ from functools import lru_cache
from contextlib import asynccontextmanager
import base64
from .net_parser import parse_guest_nics
from .mixins.vm import VmMixin
from .mixins.lxc import LxcMixin
from .mixins.cluster import ClusterMixin
@@ -1642,62 +1644,89 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.debug(f"netstat unavailable for node {node}: {e}")
return []
def get_container_interfaces(self, node: str, vmid: int) -> List[Dict]:
def get_container_interfaces(self, node: str, vmid: int,
include_live: bool = True) -> List[Dict]:
"""
Получить сетевые интерфейсы контейнера
Получить сетевые интерфейсы контейнера с их IP адресами.
LXC держит адрес внутри строки netN
("name=eth0,bridge=vmbr0,hwaddr=..,ip=10.0.0.5/24"); ключа ipconfigN у
контейнеров не бывает — это cloud-init QEMU. При ip=dhcp статики в
конфиге нет вовсе, поэтому фактические адреса запущенного контейнера
дочитываются из /nodes/{node}/lxc/{vmid}/interfaces.
Args:
node: Имя ноды
vmid: ID контейнера
include_live: опрашивать ли /interfaces (бесполезно для остановленного CT)
Returns:
Список интерфейсов с IP адресами
Список интерфейсов: {'name', 'hardware_address', 'ips': [{'address', 'type', 'prefix'}]}
"""
if not self.proxmox:
return []
interfaces: Dict[str, Dict] = {}
def _slot(name: str, mac: str = '') -> Dict:
iface = interfaces.setdefault(
name, {'name': name, 'hardware_address': '', 'ips': []}
)
if mac and not iface['hardware_address']:
iface['hardware_address'] = mac
return iface
def _add_ip(iface: Dict, value: str) -> None:
"""Принимает '10.0.0.5/24' либо '10.0.0.5'; мусор и dhcp игнорирует."""
addr, _, prefix = (value or '').strip().partition('/')
if not addr or addr in ('dhcp', 'manual', 'auto'):
return
try:
ip_obj = ipaddress.ip_address(addr)
except ValueError:
return
if ip_obj.is_loopback or ip_obj.is_link_local:
return
if any(existing['address'] == addr for existing in iface['ips']):
return
iface['ips'].append({
'address': addr,
'type': 'ipv6' if ip_obj.version == 6 else 'ipv4',
'prefix': int(prefix) if prefix.isdigit() else (128 if ip_obj.version == 6 else 32),
})
# 1) Статика из конфига контейнера
try:
# Для LXC можем получить IP из конфигурации
config = self.proxmox.nodes(node).lxc(vmid).config.get()
interfaces = []
# Проверяем net0, net1 и т.д.
for i in range(10):
net_key = f'net{i}'
if net_key in config:
net_config = config[net_key]
# Парсим строку конфигурации сети
iface_info = {'name': f'eth{i}', 'ips': []}
# Пытаемся извлечь MAC адрес
if 'hwaddr=' in net_config:
mac = net_config.split('hwaddr=')[1].split(',')[0]
iface_info['hardware_address'] = mac
interfaces.append(iface_info)
# Также проверяем IP из конфигурации
for i in range(10):
ipconfig_key = f'ipconfig{i}'
if ipconfig_key in config and interfaces and i < len(interfaces):
ipconfig = config[ipconfig_key]
# Парсим ip=192.168.1.10/24,gw=192.168.1.1
if 'ip=' in ipconfig:
ip_part = ipconfig.split('ip=')[1].split(',')[0]
if ip_part != 'dhcp':
# Разделяем IP и префикс
if '/' in ip_part:
ip, prefix = ip_part.split('/')
interfaces[i]['ips'].append({
'address': ip,
'type': 'ipv4',
'prefix': int(prefix)
})
return [iface for iface in interfaces if iface.get('ips')]
for nic in parse_guest_nics(config):
iface = _slot(nic.name or f'eth{nic.index}', nic.mac or '')
_add_ip(iface, nic.ip or '')
_add_ip(iface, nic.ip6 or '')
except Exception as e:
logger.debug(f"Не удалось получить сетевые интерфейсы контейнера {vmid}: {e}")
return []
logger.debug(f"Не удалось прочитать конфиг контейнера {vmid}: {e}")
# 2) Живые адреса — единственный источник для DHCP-контейнеров.
# Если статика уже найдена, лишний запрос не делаем: у остановленного
# контейнера этот эндпоинт всё равно отдаёт ошибку.
has_static_v4 = any(
ip_info['type'] == 'ipv4'
for iface in interfaces.values() for ip_info in iface['ips']
)
if not has_static_v4 and include_live:
try:
for iface_data in (self.proxmox.nodes(node).lxc(vmid).interfaces.get() or []):
name = iface_data.get('name') or ''
if not name or name == 'lo':
continue
iface = _slot(name, (iface_data.get('hwaddr') or '').upper())
_add_ip(iface, iface_data.get('inet') or '')
_add_ip(iface, iface_data.get('inet6') or '')
except Exception as e:
logger.debug(
f"Интерфейсы контейнера {vmid} недоступны (возможно, остановлен): {e}"
)
return [iface for iface in interfaces.values() if iface['ips']]
def execute_command(self, node: str, vmid: int, command: str, timeout: int = 30) -> Dict:
"""

View File

@@ -643,6 +643,13 @@ class IPAMAllocationResponse(IPAMAllocationBase):
last_seen: Optional[datetime] = None
created_at: datetime
updated_at: datetime
# Multi-IP: гость может держать несколько адресов
is_primary: bool = False
assignment_kind: str = "primary"
target_interface: Optional[str] = None
apply_status: Optional[str] = None
apply_error: Optional[str] = None
applied_at: Optional[datetime] = None
# Extended info
network_name: Optional[str] = None
pool_name: Optional[str] = None

View File

@@ -0,0 +1,163 @@
#!/bin/sh
# Привести набор дополнительных IPv4-адресов (alias) интерфейса к заданному.
#
# Параметры приходят переменными окружения (их подставляет
# script_engine.render_params, значения не конкатенируются в команды):
# IFACE — интерфейс гостя, напр. eth0
# ADDRESSES — список CIDR через пробел; пустая строка = снять все алиасы
# PVEM_ROOT — префикс путей; в госте всегда пуст, задаётся только тестами
#
# Скрипт идемпотентен: он синхронизирует состояние, а не «добавляет».
# Под управлением находятся только адреса из своего state-файла — адреса,
# настроенные в госте вручную, не трогаются.
#
# Сеть намеренно НЕ перезапускается: адрес поднимается через `ip addr add`
# здесь и сейчас, а конфиг ОС правится только ради переживания ребута.
# Никаких `netplan apply` / `ifup` / `nmcli con up` — они рвут связь с
# работающим гостем.
[ -n "$IFACE" ] || { echo "missing IFACE"; exit 2; }
ROOT="${PVEM_ROOT:-}"
STATE_DIR="$ROOT/var/lib/pvemanager"
STATE="$STATE_DIR/aliases.$IFACE"
mkdir -p "$STATE_DIR" 2>/dev/null || true
PREV=""
[ -f "$STATE" ] && PREV=$(cat "$STATE")
# 1. Снять адреса, ушедшие из списка (только наши, из state-файла)
for old in $PREV; do
keep=0
for new in $ADDRESSES; do
[ "$old" = "$new" ] && keep=1
done
[ "$keep" = "1" ] && continue
ip addr del "$old" dev "$IFACE" 2>/dev/null || true
done
# 2. Поднять недостающие
for a in $ADDRESSES; do
addr=${a%%/*}
if ip -4 addr show dev "$IFACE" 2>/dev/null | grep -qw "$addr"; then
continue
fi
if ! ip addr add "$a" dev "$IFACE"; then
echo "failed to add $a on $IFACE"
exit 1
fi
done
printf '%s\n' "$ADDRESSES" > "$STATE"
# 3. Закрепить в конфиге ОС — первый распознанный стек выигрывает.
# Ветки идут последовательно, а не через elif: наличие инструмента ещё не
# значит, что он управляет этим интерфейсом (типичный случай — NetworkManager
# в LXC), и тогда управление должно уходить дальше по списку.
persist=none
MARKER="# managed by PVEmanager"
IFACES_FILE="$ROOT/etc/network/interfaces"
IFUP_DIR="$ROOT/etc/network/interfaces.d"
IFUP_FILE="$IFUP_DIR/pvemanager-aliases-$IFACE"
NETPLAN_DIR="$ROOT/etc/netplan"
NETPLAN_FILE="$NETPLAN_DIR/99-pvemanager-aliases.yaml"
SYSCONFIG_DIR="$ROOT/etc/sysconfig/network-scripts"
# --- ifupdown (Debian/Ubuntu) ---
if [ "$persist" = none ] && [ -d "$IFUP_DIR" ] && [ -f "$IFACES_FILE" ] && \
command -v ifup >/dev/null 2>&1; then
# Шаблоны LXC от Proxmox создают interfaces.d, но не подключают его из
# /etc/network/interfaces — без source-строки файлы оттуда не читаются,
# поэтому добавляем её (идемпотентно). Список интерфейсов не трогаем:
# его перезаписывает Proxmox при правке NIC.
if ! grep -qE '^[[:space:]]*(source|source-directory)' "$IFACES_FILE"; then
echo "source /etc/network/interfaces.d/*" >> "$IFACES_FILE"
fi
if [ -z "$ADDRESSES" ]; then
rm -f "$IFUP_FILE"
else
{
echo "$MARKER — не редактировать вручную"
n=0
for a in $ADDRESSES; do
n=$((n + 1))
echo "auto $IFACE:$n"
echo "iface $IFACE:$n inet static"
echo " address $a"
done
} > "$IFUP_FILE"
fi
persist=ifupdown
fi
# --- netplan (Ubuntu cloud-образы) ---
if [ "$persist" = none ] && [ -d "$NETPLAN_DIR" ]; then
if [ -z "$ADDRESSES" ]; then
rm -f "$NETPLAN_FILE"
else
{
echo "$MARKER — не редактировать вручную"
echo "network:"
echo " version: 2"
echo " ethernets:"
echo " $IFACE:"
echo " addresses:"
for a in $ADDRESSES; do
echo " - $a"
done
} > "$NETPLAN_FILE"
chmod 600 "$NETPLAN_FILE" 2>/dev/null || true
fi
persist=netplan
fi
# --- NetworkManager (RHEL-based VM, современные десктопные образы) ---
if [ "$persist" = none ] && command -v nmcli >/dev/null 2>&1; then
# Пустой ответ = интерфейсом NM не управляет; тогда идём дальше.
CON=$(nmcli -g GENERAL.CONNECTION device show "$IFACE" 2>/dev/null)
if [ -n "$CON" ]; then
for old in $PREV; do
nmcli con mod "$CON" -ipv4.addresses "$old" >/dev/null 2>&1 || true
done
for a in $ADDRESSES; do
nmcli con mod "$CON" +ipv4.addresses "$a" >/dev/null 2>&1 || true
done
persist=nm
fi
fi
# --- sysconfig / network-scripts (RHEL, CentOS, Rocky, Alma) ---
# Шаблоны LXC этих дистрибутивов Proxmox настраивает именно так
# (pve-container, модуль Redhat.pm), а NetworkManager в них обычно не стоит.
if [ "$persist" = none ] && [ -d "$SYSCONFIG_DIR" ]; then
# Сносим прежние свои ifcfg-алиасы; чужие файлы без маркера не трогаем.
for f in "$SYSCONFIG_DIR/ifcfg-$IFACE":*; do
[ -f "$f" ] || continue
if head -n 1 "$f" 2>/dev/null | grep -q "$MARKER"; then
rm -f "$f"
fi
done
n=0
for a in $ADDRESSES; do
n=$((n + 1))
addr=${a%%/*}
plen=${a##*/}
{
echo "$MARKER — не редактировать вручную"
echo "DEVICE=$IFACE:$n"
echo "NAME=$IFACE:$n"
echo "ONPARENT=yes"
echo "ONBOOT=yes"
echo "BOOTPROTO=none"
echo "IPADDR=$addr"
echo "PREFIX=$plen"
} > "$SYSCONFIG_DIR/ifcfg-$IFACE:$n"
done
persist=sysconfig
fi
echo "PVEMANAGER_PERSIST=$persist"
echo "PVEMANAGER_OK=1"

View File

@@ -0,0 +1,222 @@
"""Дополнительные IP-адреса гостя (alias поверх основного интерфейса).
Основной адрес живёт в конфиге Proxmox (`net0` у LXC, `ipconfig0` у QEMU) и
здесь не трогается. Дополнительные навешиваются внутри гостя как alias и
учитываются в IPAM: одна аллокация — один адрес.
Транспорт не изобретаем: `services/script_engine.py` уже умеет выполнять
скрипт и в LXC (`pct exec` по SSH ноды), и в VM (QEMU guest agent).
Модуль работает синхронизацией, а не пошаговым add/remove: в гостя всегда
уезжает полный список alias-адресов интерфейса, а скрипт приводит состояние
к нему. Поэтому добавление, удаление и повторное применение — один и тот же
код, а расхождения (адрес сняли руками) чинятся сами.
"""
from __future__ import annotations
import ipaddress
import re
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple
from loguru import logger
from sqlalchemy.orm import Session
from ..config import utcnow
from ..models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
from . import script_engine
_SCRIPT_PATH = Path(__file__).parent / "guest_ip" / "sync_aliases.sh"
_SYNC_TIMEOUT = 60
# Значения IPAMAllocation.apply_status
APPLIED = "applied" # поднят и закреплён в конфиге ОС
RUNTIME_ONLY = "runtime_only" # поднят, но стек не распознан — ребут не переживёт
PENDING = "pending" # гость недоступен, попробуем позже
FAILED = "failed" # скрипт отработал с ошибкой
_PERSIST_RE = re.compile(r"^PVEMANAGER_PERSIST=(\w+)$", re.MULTILINE)
_OK_RE = re.compile(r"^PVEMANAGER_OK=1$", re.MULTILINE)
_IFACE_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,15}$")
DEFAULT_INTERFACE = "eth0"
@dataclass
class ApplyResult:
success: bool
status: str
persist: Optional[str] = None
error: Optional[str] = None
output: str = ""
def _load_script() -> str:
return _SCRIPT_PATH.read_text(encoding="utf-8")
def prefix_for(db: Session, allocation: IPAMAllocation) -> Optional[int]:
"""Длина префикса адреса — берётся из сети, которой он принадлежит."""
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == allocation.network_id).first()
if not network or not network.network:
return None
try:
return ipaddress.ip_network(network.network, strict=False).prefixlen
except ValueError:
return None
def alias_allocations(db: Session, server_id: int, vmid: int,
interface: str) -> List[IPAMAllocation]:
"""Все alias-адреса гостя на конкретном интерфейсе, по возрастанию адреса."""
rows = db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_server_id == server_id,
IPAMAllocation.proxmox_vmid == vmid,
IPAMAllocation.assignment_kind == "alias",
IPAMAllocation.target_interface == interface,
IPAMAllocation.status.in_(["allocated", "reserved"]),
).all()
def _sort_key(row: IPAMAllocation):
try:
return (0, int(ipaddress.ip_address(row.ip_address)))
except ValueError:
return (1, 0)
return sorted(rows, key=_sort_key)
def _guest_target(db: Session, server_id: int, vmid: int,
allocation: Optional[IPAMAllocation] = None
) -> Tuple[Optional[ProxmoxServer], Optional[str], Optional[str]]:
"""(server, node, vm_type) для запуска скрипта; None-ы, если гость неизвестен."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
guest = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None),
).first()
node = (guest.node if guest else None) or (allocation.proxmox_node if allocation else None)
vm_type = guest.vm_type if guest else None
if not vm_type and allocation and allocation.resource_type in ("qemu", "lxc"):
vm_type = allocation.resource_type
return server, node, vm_type
def resolve_interface(client, vm_type: str, node: str, vmid: int,
primary_ip: Optional[str] = None) -> str:
"""Интерфейс по умолчанию — тот, на котором висит основной адрес гостя.
Данные берём у существующих методов клиента; если гость молчит,
возвращаем eth0 — пользователь всё равно может выбрать интерфейс вручную.
"""
try:
if vm_type == "lxc":
interfaces = client.get_container_interfaces(node, vmid)
else:
interfaces = client.get_vm_interfaces(node, vmid)
except Exception as e:
logger.debug(f"[guest-ip] не удалось получить интерфейсы {vmid}: {e}")
return DEFAULT_INTERFACE
fallback = None
for iface in interfaces or []:
name = iface.get("name") or ""
if not name or name == "lo":
continue
fallback = fallback or name
if primary_ip:
for ip_info in iface.get("ips") or []:
if ip_info.get("address") == primary_ip:
return name
return fallback or DEFAULT_INTERFACE
def sync_interface(db: Session, server_id: int, vmid: int, interface: str,
allocation: Optional[IPAMAllocation] = None) -> ApplyResult:
"""Привести alias-адреса интерфейса в госте к тому, что записано в IPAM.
Обновляет apply_status у всех alias-аллокаций этого интерфейса: они
применяются одним вызовом, значит и исход у них общий.
"""
if not _IFACE_RE.match(interface or ""):
return ApplyResult(False, FAILED, error=f"Некорректное имя интерфейса: {interface}")
server, node, vm_type = _guest_target(db, server_id, vmid, allocation)
rows = alias_allocations(db, server_id, vmid, interface)
if not server:
return _finish(db, rows, ApplyResult(False, PENDING, error="Сервер Proxmox не найден"))
if not node or not vm_type:
return _finish(db, rows, ApplyResult(
False, PENDING, error="Гость не найден в кэше — синхронизируйте инстансы"))
addresses = []
for row in rows:
prefix = prefix_for(db, row)
if prefix is None:
return _finish(db, rows, ApplyResult(
False, FAILED,
error=f"Не удалось определить префикс сети для {row.ip_address}"))
addresses.append(f"{row.ip_address}/{prefix}")
script = script_engine.render_params(
_load_script(), {"IFACE": interface, "ADDRESSES": " ".join(addresses)}
)
result = script_engine.execute(
server, target_type="guest", node=node, vmid=vmid, vm_type=vm_type,
script=script, interpreter="/bin/sh", timeout=_SYNC_TIMEOUT,
)
output = result.output or ""
if not result.success or not _OK_RE.search(output):
error = (result.error or output or "").strip()[:1000] or "Скрипт не завершился успешно"
# exit_code == -1 — транспорт: гость выключен, нет SSH к ноде или нет
# guest agent. Это не ошибка конфигурации, поэтому pending: адрес
# закреплён в IPAM и применится, когда гость станет доступен.
status = PENDING if result.exit_code == -1 else FAILED
return _finish(db, rows, ApplyResult(False, status, error=error, output=output))
persist_match = _PERSIST_RE.search(output)
persist = persist_match.group(1) if persist_match else "none"
status = RUNTIME_ONLY if persist == "none" else APPLIED
return _finish(db, rows, ApplyResult(True, status, persist=persist, output=output))
def _finish(db: Session, rows: List[IPAMAllocation], result: ApplyResult) -> ApplyResult:
"""Записать исход применения во все затронутые аллокации."""
for row in rows:
row.apply_status = result.status
row.apply_error = result.error
if result.success:
row.applied_at = utcnow()
db.commit()
return result
def apply_address(db: Session, allocation: IPAMAllocation) -> ApplyResult:
"""Применить (или переприменить) alias-адрес к гостю."""
if allocation.assignment_kind != "alias":
return ApplyResult(True, APPLIED, persist="primary",
output="Основной адрес задаётся конфигом Proxmox")
return sync_interface(
db, allocation.proxmox_server_id, allocation.proxmox_vmid,
allocation.target_interface or DEFAULT_INTERFACE, allocation,
)
def remove_address(db: Session, allocation: IPAMAllocation) -> ApplyResult:
"""Снять alias-адрес с гостя.
К моменту вызова аллокация должна быть уже освобождена или удалена —
скрипт снимает всё, чего нет в переданном ему списке.
"""
if allocation.assignment_kind != "alias":
return ApplyResult(True, APPLIED, persist="primary")
return sync_interface(
db, allocation.proxmox_server_id, allocation.proxmox_vmid,
allocation.target_interface or DEFAULT_INTERFACE, allocation,
)

View File

@@ -5,6 +5,7 @@ Uses APScheduler for periodic tasks
import asyncio
import re
import time
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, List, Optional
from loguru import logger
@@ -108,12 +109,17 @@ class MonitoringWorker:
# задачу failed. Транзитный сетевой сбой не должен «убивать» живую задачу.
UPID_FAIL_THRESHOLD = 3
# Как часто перепроверять IP гостя у Proxmox. Синк идёт каждые 10 секунд,
# а адрес меняется редко — без троттлинга это сотни лишних запросов в минуту.
IP_DETECT_TTL = 300
def __init__(self):
self.last_vm_states: Dict[str, str] = {} # vm_id -> status
self.last_resource_alerts: Dict[str, float] = {} # resource_id -> last_alert_time
self.last_server_states: Dict[int, bool] = {} # server_id -> is_online
self.last_server_alerts: Dict[int, float] = {} # server_id -> last_alert_time
self.upid_fail_counts: Dict[int, int] = {} # ProxmoxTask.id -> consecutive fetch failures
self.last_ip_detect: Dict[tuple, float] = {} # (server_id, vmid) -> monotonic time
def _create_proxmox_client(self, server: ProxmoxServer) -> ProxmoxClient:
"""Create ProxmoxClient from server model, raising if auth is missing."""
@@ -619,18 +625,20 @@ class MonitoringWorker:
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
# Build IPAM lookups
# Build IPAM lookups.
# Только по идентификатору гостя: сопоставление по имени давало чужой
# адрес одноимённым гостям на разных серверах (два разных "wireguard").
# У гостя может быть несколько адресов — в кэш инстанса пишем
# основной (is_primary); без флага берём самую раннюю запись.
ipam_by_server_vmid = {} # (server_id, vmid) -> allocation
ipam_by_name = {} # hostname/resource_name -> allocation
for alloc in ipam_allocations:
if alloc.proxmox_server_id and alloc.proxmox_vmid:
ipam_by_server_vmid[(alloc.proxmox_server_id, alloc.proxmox_vmid)] = alloc
if alloc.hostname:
ipam_by_name[alloc.hostname.lower()] = alloc
if alloc.resource_name:
ipam_by_name[alloc.resource_name.lower()] = alloc
key = (alloc.proxmox_server_id, alloc.proxmox_vmid)
current = ipam_by_server_vmid.get(key)
if current is None or (alloc.is_primary and not current.is_primary):
ipam_by_server_vmid[key] = alloc
# Detect clusters: servers that see multiple nodes are part of a cluster
# Group servers by their node set
server_nodes = {} # server_id -> set of node names
@@ -749,26 +757,34 @@ class MonitoringWorker:
all_seen.add((server_id, vmid))
# Get IP from IPAM
ipam_alloc = (
ipam_by_server_vmid.get((server_id, vmid)) or
ipam_by_name.get(vm_name.lower())
)
ipam_alloc = ipam_by_server_vmid.get((server_id, vmid))
ip_address = ipam_alloc.ip_address if ipam_alloc else None
# If no IPAM IP, try to detect from Proxmox (LXC config or QEMU guest agent)
if not ip_address:
# If no IPAM IP, try to detect from Proxmox (LXC config or QEMU guest agent).
# Троттлим: адрес гостя меняется куда реже, чем идёт синк.
detect_key = (server_id, vmid)
detect_due = (time.monotonic() - self.last_ip_detect.get(detect_key, 0.0)) >= self.IP_DETECT_TTL
if not ip_address and detect_due:
self.last_ip_detect[detect_key] = time.monotonic()
try:
px_client = server_clients.get(server_id)
if px_client and node_name:
if vm_type == 'lxc':
config = px_client.proxmox.nodes(node_name).lxc(vmid).config.get()
for i in range(4):
ipconfig = config.get(f'ipconfig{i}', '')
if 'ip=' in str(ipconfig):
ip_part = str(ipconfig).split('ip=')[1].split(',')[0]
if ip_part and ip_part != 'dhcp':
ip_address = ip_part.split('/')[0] if '/' in ip_part else ip_part
# Статика из netN + живые адреса запущенного
# контейнера (DHCP виден только там)
for iface in px_client.get_container_interfaces(
node_name, vmid,
include_live=vm_data.get('status') == 'running',
):
for ip_info in iface.get('ips') or []:
if ip_info.get('type') != 'ipv4':
continue
addr = ip_info.get('address', '')
if addr:
ip_address = addr
break
if ip_address:
break
elif vm_type == 'qemu' and vm_data.get('status') == 'running':
result = px_client.proxmox.nodes(node_name).qemu(vmid).agent('network-get-interfaces').get()
if result and 'result' in result:
@@ -862,6 +878,11 @@ class MonitoringWorker:
db.rollback()
return
# Не копим троттлинг-метки по гостям, которых больше нет
self.last_ip_detect = {
key: seen_at for key, seen_at in self.last_ip_detect.items() if key in all_seen
}
# Mark VMs that no longer exist as deleted
# Get all active VMs from synced servers
synced_server_ids = set(server.id for server in servers)

View File

@@ -1838,6 +1838,48 @@ def migrate_app_operations_server_id(conn):
logger.info("✓ app_operations.server_id already exists")
def migrate_ipam_multi_ip(conn):
"""Migration 43: несколько IP-адресов на одного гостя.
Раньше на (server_id, vmid) подразумевалась ровно одна аллокация. Теперь
их может быть несколько: один основной (net0/ipconfig0, попадает в колонку
IP) и произвольное число алиасов, навешенных внутри гостя. Колонки
apply_* хранят состояние применения алиаса к живому гостю.
"""
if not table_exists(conn, 'ipam_allocations'):
logger.info("Table ipam_allocations does not exist, skipping multi-IP migration")
return
added = False
for column, ddl in (
('is_primary', 'BOOLEAN NOT NULL DEFAULT FALSE'),
('assignment_kind', "VARCHAR(10) NOT NULL DEFAULT 'primary'"),
('target_interface', 'VARCHAR(20)'),
('apply_status', 'VARCHAR(20)'),
('apply_error', 'TEXT'),
('applied_at', 'TIMESTAMP WITH TIME ZONE'),
):
if add_column_if_not_exists(conn, 'ipam_allocations', column, ddl):
added = True
if added:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_ipam_alloc_primary "
"ON ipam_allocations(proxmox_server_id, proxmox_vmid, is_primary)"
))
# Всё, что существовало до этой миграции, было единственным адресом
# своего гостя — значит основным. Записи без привязки к гостю
# (ручные брони) основными не считаем.
conn.execute(text("""
UPDATE ipam_allocations
SET is_primary = TRUE, assignment_kind = 'primary'
WHERE proxmox_vmid IS NOT NULL AND is_primary = FALSE
"""))
logger.info("✓ Added multi-IP columns to ipam_allocations")
else:
logger.info("✓ ipam_allocations multi-IP columns already exist")
def migrate_setting_view_panel_only(conn):
"""Migration 42: drop setting:view where it was only granting the profile page.
@@ -2247,6 +2289,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"setting:view split migration: {e}")
conn.rollback()
# Migration 43: several IP addresses per guest
try:
migrate_ipam_multi_ip(conn)
conn.commit()
except Exception as e:
logger.warning(f"IPAM multi-IP migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)

View File

@@ -0,0 +1,105 @@
from unittest.mock import MagicMock
from app.proxmox.client import ProxmoxClient
def _client_with_mock(proxmox):
c = ProxmoxClient.__new__(ProxmoxClient)
c.proxmox = proxmox
return c
def _lxc_mock(config=None, interfaces=None):
"""MagicMock whose nodes(..).lxc(..).config/.interfaces behave like Proxmox."""
px = MagicMock()
lxc = px.nodes.return_value.lxc.return_value
if isinstance(config, Exception):
lxc.config.get.side_effect = config
else:
lxc.config.get.return_value = config or {}
if isinstance(interfaces, Exception):
lxc.interfaces.get.side_effect = interfaces
else:
lxc.interfaces.get.return_value = interfaces or []
return px, lxc
def test_static_ip_comes_from_net0_not_ipconfig():
px, lxc = _lxc_mock(config={
"hostname": "gitea",
"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:AA:BB:CC,ip=10.10.10.9/24,gw=10.10.10.1,type=veth",
})
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 109)
assert ifaces == [{
"name": "eth0",
"hardware_address": "BC:24:11:AA:BB:CC",
"ips": [{"address": "10.10.10.9", "type": "ipv4", "prefix": 24}],
}]
# Static address is authoritative — no extra call to the live endpoint.
lxc.interfaces.get.assert_not_called()
def test_dhcp_container_falls_back_to_live_interfaces():
px, _ = _lxc_mock(
config={"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:00:11:22,ip=dhcp,type=veth"},
interfaces=[
{"name": "lo", "inet": "127.0.0.1/8"},
{"name": "eth0", "hwaddr": "bc:24:11:00:11:22", "inet": "10.10.10.55/24",
"inet6": "fe80::be24:11ff:fe00:1122/64"},
],
)
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 120)
assert ifaces == [{
"name": "eth0",
"hardware_address": "BC:24:11:00:11:22",
"ips": [{"address": "10.10.10.55", "type": "ipv4", "prefix": 24}],
}]
def test_stopped_dhcp_container_yields_nothing():
px, _ = _lxc_mock(
config={"net0": "name=eth0,bridge=vmbr0,ip=dhcp,type=veth"},
interfaces=RuntimeError("500 CT is not running"),
)
assert _client_with_mock(px).get_container_interfaces("pve1", 120) == []
def test_multiple_nics_and_ipv6_static():
px, _ = _lxc_mock(config={
"net0": "name=eth0,bridge=vmbr0,ip=10.10.10.9/24,gw=10.10.10.1",
"net1": "name=eth1,bridge=vmbr1,ip=192.168.50.4/24,ip6=2001:db8::4/64",
})
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 121)
assert [i["name"] for i in ifaces] == ["eth0", "eth1"]
assert ifaces[1]["ips"] == [
{"address": "192.168.50.4", "type": "ipv4", "prefix": 24},
{"address": "2001:db8::4", "type": "ipv6", "prefix": 64},
]
def test_unreadable_config_still_tries_live_interfaces():
px, _ = _lxc_mock(
config=RuntimeError("permission denied"),
interfaces=[{"name": "eth0", "hwaddr": "aa:bb:cc:dd:ee:ff", "inet": "10.0.0.7/24"}],
)
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 122)
assert ifaces[0]["ips"] == [{"address": "10.0.0.7", "type": "ipv4", "prefix": 24}]
def test_no_proxmox_connection():
c = ProxmoxClient.__new__(ProxmoxClient)
c.proxmox = None
assert c.get_container_interfaces("pve1", 100) == []
def test_include_live_false_skips_live_endpoint():
px, lxc = _lxc_mock(
config={"net0": "name=eth0,bridge=vmbr0,ip=dhcp,type=veth"},
interfaces=[{"name": "eth0", "inet": "10.0.0.8/24"}],
)
# Stopped container: querying /interfaces would only produce a 500.
assert _client_with_mock(px).get_container_interfaces("pve1", 130, include_live=False) == []
lxc.interfaces.get.assert_not_called()

View File

@@ -0,0 +1,298 @@
"""Скрипт синхронизации alias-адресов: детект стека и содержимое конфигов.
Скрипт запускается настоящим /bin/sh в песочнице: PVEM_ROOT подменяет корень
файловой системы, а ip/nmcli/ifup подставляются заглушками через PATH. Так
проверяется именно shell-логика — то, что реально выполняется внутри гостя.
"""
import os
import subprocess
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "app" / "services" / "guest_ip" / "sync_aliases.sh"
FAKE_IP = """#!/bin/sh
echo "$*" >> "$FAKE_LOG/ip.log"
# 'addr show' печатает пустоту: считаем, что на интерфейсе адресов ещё нет
exit 0
"""
FAKE_NMCLI = """#!/bin/sh
echo "$*" >> "$FAKE_LOG/nmcli.log"
if [ "$1" = "-g" ]; then
printf '%s' "$NM_CONNECTION"
fi
exit 0
"""
FAKE_IFUP = """#!/bin/sh
exit 0
"""
@pytest.fixture
def sandbox(tmp_path):
"""Песочница с фейковыми ip/nmcli/ifup и пустым «корнем» гостя."""
root = tmp_path / "root"
bindir = tmp_path / "bin"
logdir = tmp_path / "log"
for path in (root, bindir, logdir):
path.mkdir(parents=True)
for name, body in (("ip", FAKE_IP), ("nmcli", FAKE_NMCLI), ("ifup", FAKE_IFUP)):
target = bindir / name
target.write_text(body)
target.chmod(0o755)
def run(addresses="", iface="eth0", nm_connection="", tools=("ip",)):
"""Запустить скрипт. tools — какие команды «установлены» в госте."""
env = {
"PATH": f"{bindir}:{os.environ['PATH']}",
"PVEM_ROOT": str(root),
"FAKE_LOG": str(logdir),
"NM_CONNECTION": nm_connection,
"IFACE": iface,
"ADDRESSES": addresses,
}
# Прячем инструменты, которых в этом сценарии быть не должно
for name in ("ip", "nmcli", "ifup"):
path = bindir / name
hidden = bindir / f"{name}.hidden"
if name in tools and hidden.exists():
hidden.rename(path)
elif name not in tools and path.exists():
path.rename(hidden)
result = subprocess.run(
["/bin/sh", str(SCRIPT)], env=env, capture_output=True, text=True, timeout=30
)
return result
def persist_of(result) -> str:
for line in result.stdout.splitlines():
if line.startswith("PVEMANAGER_PERSIST="):
return line.split("=", 1)[1]
raise AssertionError(f"нет маркера persist: {result.stdout} {result.stderr}")
def log(name: str) -> str:
path = logdir / f"{name}.log"
return path.read_text() if path.exists() else ""
return {"root": root, "run": run, "persist_of": persist_of, "log": log}
def _mkdirs(root: Path, *relative: str):
for item in relative:
(root / item).mkdir(parents=True, exist_ok=True)
# --- sysconfig / network-scripts ----------------------------------------------
def test_sysconfig_branch_writes_ifcfg_alias(sandbox):
"""RHEL-based LXC: alias закрепляется отдельным ifcfg-файлом."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24")
assert sandbox["persist_of"](result) == "sysconfig"
written = (root / "etc/sysconfig/network-scripts/ifcfg-eth0:1").read_text()
assert "DEVICE=eth0:1" in written
assert "IPADDR=10.10.10.90" in written
assert "PREFIX=24" in written
assert "BOOTPROTO=none" in written
assert "ONBOOT=yes" in written
assert "managed by PVEmanager" in written
def test_sysconfig_numbers_aliases_in_order(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
scripts = root / "etc/sysconfig/network-scripts"
assert "IPADDR=10.10.10.90" in (scripts / "ifcfg-eth0:1").read_text()
assert "IPADDR=10.10.10.91" in (scripts / "ifcfg-eth0:2").read_text()
def test_sysconfig_drops_files_of_removed_addresses(sandbox):
"""Список сократился — лишний ifcfg удаляется, оставшийся переписывается."""
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
sandbox["run"](addresses="10.10.10.91/24")
assert not (scripts / "ifcfg-eth0:2").exists()
assert "IPADDR=10.10.10.91" in (scripts / "ifcfg-eth0:1").read_text()
def test_sysconfig_removes_everything_on_empty_list(sandbox):
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24")
result = sandbox["run"](addresses="")
assert sandbox["persist_of"](result) == "sysconfig"
assert list(scripts.glob("ifcfg-eth0:*")) == []
def test_sysconfig_keeps_foreign_ifcfg_files(sandbox):
"""Файлы, написанные не нами, остаются нетронутыми."""
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
foreign = scripts / "ifcfg-eth0:9"
foreign.write_text("DEVICE=eth0:9\nIPADDR=10.10.10.250\n")
sandbox["run"](addresses="10.10.10.90/24")
assert foreign.exists()
assert "10.10.10.250" in foreign.read_text()
def test_sysconfig_removes_alias_del_from_interface(sandbox):
"""Ушедший адрес снимается с интерфейса, а не только из конфига."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24")
sandbox["run"](addresses="")
assert "addr del 10.10.10.90/24 dev eth0" in sandbox["log"]("ip")
# --- порядок веток -------------------------------------------------------------
def test_networkmanager_without_connection_falls_through_to_sysconfig(sandbox):
"""nmcli установлен, но интерфейсом не управляет — частый случай в LXC."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="",
tools=("ip", "nmcli"))
assert sandbox["persist_of"](result) == "sysconfig"
assert (root / "etc/sysconfig/network-scripts/ifcfg-eth0:1").exists()
def test_networkmanager_with_connection_wins_over_sysconfig(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
tools=("ip", "nmcli"))
assert sandbox["persist_of"](result) == "nm"
assert "+ipv4.addresses 10.10.10.90/24" in sandbox["log"]("nmcli")
assert list((root / "etc/sysconfig/network-scripts").glob("ifcfg-*")) == []
def test_networkmanager_drops_previous_address(sandbox):
_mkdirs(sandbox["root"], "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
tools=("ip", "nmcli"))
sandbox["run"](addresses="10.10.10.91/24", nm_connection="System eth0",
tools=("ip", "nmcli"))
assert "-ipv4.addresses 10.10.10.90/24" in sandbox["log"]("nmcli")
def test_ifupdown_wins_when_several_stacks_present(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/network/interfaces.d", "etc/netplan", "etc/sysconfig/network-scripts")
(root / "etc/network/interfaces").write_text("auto eth0\niface eth0 inet dhcp\n")
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
tools=("ip", "nmcli", "ifup"))
assert sandbox["persist_of"](result) == "ifupdown"
written = (root / "etc/network/interfaces.d/pvemanager-aliases-eth0").read_text()
assert "iface eth0:1 inet static" in written
assert "address 10.10.10.90/24" in written
# source-строка дописывается, раз шаблон её не содержит
assert "source /etc/network/interfaces.d/*" in (root / "etc/network/interfaces").read_text()
def test_ifupdown_needs_ifup_present(sandbox):
"""Каталог interfaces.d без ifupdown — не наш стек, уходим в sysconfig."""
root = sandbox["root"]
_mkdirs(root, "etc/network/interfaces.d", "etc/sysconfig/network-scripts")
(root / "etc/network/interfaces").write_text("auto eth0\n")
result = sandbox["run"](addresses="10.10.10.90/24", tools=("ip",))
assert sandbox["persist_of"](result) == "sysconfig"
def test_netplan_branch_writes_yaml(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/netplan", "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
assert sandbox["persist_of"](result) == "netplan"
written = (root / "etc/netplan/99-pvemanager-aliases.yaml").read_text()
assert " eth0:" in written
assert " - 10.10.10.90/24" in written
assert " - 10.10.10.91/24" in written
def test_no_known_stack_reports_none_but_still_adds_address(sandbox):
"""Адрес поднимается всегда; не закрепился — об этом говорит persist=none."""
result = sandbox["run"](addresses="10.10.10.90/24")
assert sandbox["persist_of"](result) == "none"
assert "addr add 10.10.10.90/24 dev eth0" in sandbox["log"]("ip")
assert "PVEMANAGER_OK=1" in result.stdout
# --- общие инварианты ----------------------------------------------------------
def test_state_file_tracks_managed_addresses(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
assert (root / "var/lib/pvemanager/aliases.eth0").read_text().strip() == \
"10.10.10.90/24 10.10.10.91/24"
def test_second_run_is_idempotent(sandbox):
"""Повтор с тем же списком не должен ни удалять, ни добавлять заново."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24")
before = sandbox["log"]("ip")
sandbox["run"](addresses="10.10.10.90/24")
added_again = sandbox["log"]("ip")[len(before):]
assert "addr del" not in added_again
def test_other_interface_aliases_are_untouched(sandbox):
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24", iface="eth0")
sandbox["run"](addresses="10.10.10.91/24", iface="eth1")
assert (scripts / "ifcfg-eth0:1").exists()
assert (scripts / "ifcfg-eth1:1").exists()
def test_missing_iface_is_rejected(sandbox):
result = sandbox["run"](addresses="10.10.10.90/24", iface="")
assert result.returncode == 2
assert "missing IFACE" in result.stdout

View File

@@ -0,0 +1,218 @@
"""Дополнительные адреса гостя: что уезжает в гостя и как трактуется ответ."""
import pytest
from app.ipam_service import IPAMService
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
from app.services import guest_ip_service, script_engine
OK_IFUPDOWN = "PVEMANAGER_PERSIST=ifupdown\nPVEMANAGER_OK=1\n"
OK_NO_PERSIST = "PVEMANAGER_PERSIST=none\nPVEMANAGER_OK=1\n"
class _Recorder:
"""Подменяет script_engine.execute и запоминает, что ушло в гостя."""
def __init__(self, result):
self.result = result
self.calls = []
def __call__(self, server, **kwargs):
self.calls.append(kwargs)
return self.result
@property
def last_script(self) -> str:
return self.calls[-1]["script"]
def exported(self, name: str) -> str:
"""Значение переменной из блока export, который добавляет render_params."""
for line in self.last_script.splitlines():
if line.startswith(f"export {name}="):
return line.split("=", 1)[1].strip().strip("'")
raise AssertionError(f"{name} not exported: {self.last_script[:200]}")
@pytest.fixture
def guest(db_session):
server = ProxmoxServer(name="Devserver", ip_address="192.168.43.254", hostname="dev",
api_user="root@pam", use_password=True, password="pw")
db_session.add(server)
db_session.commit()
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24", gateway="10.10.10.1",
is_active=True)
db_session.add(network)
db_session.commit()
instance = VMInstance(server_id=server.id, vmid=120, node="dev", vm_type="lxc",
name="act-runner", status="running")
db_session.add(instance)
db_session.commit()
service = IPAMService(db_session)
def add_alias(ip, interface="eth0"):
allocation, error = service.allocate_ip(
ip_address=ip, network_id=network.id, resource_type="lxc",
resource_name="act-runner", proxmox_server_id=server.id, proxmox_vmid=120,
proxmox_node="dev", allocated_by="tester", assignment_kind="alias",
target_interface=interface, apply_status=guest_ip_service.PENDING,
)
assert error is None
return allocation
return {"db": db_session, "server": server, "network": network, "instance": instance,
"service": service, "add_alias": add_alias}
def _patch(monkeypatch, result):
recorder = _Recorder(result)
monkeypatch.setattr(guest_ip_service.script_engine, "execute", recorder)
return recorder
def test_apply_sends_every_alias_of_the_interface(guest, monkeypatch):
"""В гостя уезжает полный список адресов, а не только новый."""
guest["add_alias"]("10.10.10.90")
second = guest["add_alias"]("10.10.10.91")
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
result = guest_ip_service.apply_address(guest["db"], second)
assert result.success and result.status == guest_ip_service.APPLIED
assert recorder.exported("IFACE") == "eth0"
assert recorder.exported("ADDRESSES") == "10.10.10.90/24 10.10.10.91/24"
assert recorder.calls[-1]["vm_type"] == "lxc"
assert recorder.calls[-1]["vmid"] == 120
def test_prefix_comes_from_the_ipam_network(guest, monkeypatch):
guest["network"].network = "10.10.0.0/16"
guest["db"].commit()
allocation = guest["add_alias"]("10.10.10.90")
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
guest_ip_service.apply_address(guest["db"], allocation)
assert recorder.exported("ADDRESSES") == "10.10.10.90/16"
def test_unrecognised_stack_is_runtime_only(guest, monkeypatch):
allocation = guest["add_alias"]("10.10.10.90")
_patch(monkeypatch, script_engine.ExecResult(True, OK_NO_PERSIST, 0))
result = guest_ip_service.apply_address(guest["db"], allocation)
assert result.success
assert result.status == guest_ip_service.RUNTIME_ONLY
assert allocation.apply_status == guest_ip_service.RUNTIME_ONLY
assert allocation.applied_at is not None
def test_unreachable_guest_keeps_allocation_pending(guest, monkeypatch):
"""Гость выключен или нет SSH — адрес остаётся за ним, статус pending."""
allocation = guest["add_alias"]("10.10.10.90")
_patch(monkeypatch, script_engine.ExecResult(
False, "", -1, error="Не удалось подключиться по SSH к Devserver"))
result = guest_ip_service.apply_address(guest["db"], allocation)
assert not result.success
assert result.status == guest_ip_service.PENDING
assert allocation.apply_status == guest_ip_service.PENDING
assert "SSH" in allocation.apply_error
assert allocation.applied_at is None
# Аллокация никуда не делась — адрес зарезервирован
assert guest["db"].query(IPAMAllocation).count() == 1
def test_script_error_marks_allocation_failed(guest, monkeypatch):
allocation = guest["add_alias"]("10.10.10.90")
_patch(monkeypatch, script_engine.ExecResult(
False, "failed to add 10.10.10.90/24 on eth0", 1))
result = guest_ip_service.apply_address(guest["db"], allocation)
assert result.status == guest_ip_service.FAILED
assert allocation.apply_status == guest_ip_service.FAILED
def test_missing_ok_marker_is_not_treated_as_success(guest, monkeypatch):
"""exit=0 без маркера — скрипт не доработал; успехом это не считаем."""
allocation = guest["add_alias"]("10.10.10.90")
_patch(monkeypatch, script_engine.ExecResult(True, "some noise", 0))
result = guest_ip_service.apply_address(guest["db"], allocation)
assert not result.success and result.status == guest_ip_service.FAILED
def test_released_address_disappears_from_the_synced_list(guest, monkeypatch):
"""Снятие адреса = синхронизация без него: скрипт сам сделает ip addr del."""
keep = guest["add_alias"]("10.10.10.90")
drop = guest["add_alias"]("10.10.10.91")
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
guest["service"].release_ip(drop.ip_address, released_by="tester")
guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth0")
assert recorder.exported("ADDRESSES") == "10.10.10.90/24"
assert keep.apply_status == guest_ip_service.APPLIED
def test_last_address_removal_sends_empty_list(guest, monkeypatch):
only = guest["add_alias"]("10.10.10.90")
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
guest["service"].release_ip(only.ip_address, released_by="tester")
guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth0")
assert recorder.exported("ADDRESSES") == ""
def test_addresses_of_other_interfaces_are_not_touched(guest, monkeypatch):
guest["add_alias"]("10.10.10.90", interface="eth0")
guest["add_alias"]("10.10.10.91", interface="eth1")
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth1")
assert recorder.exported("IFACE") == "eth1"
assert recorder.exported("ADDRESSES") == "10.10.10.91/24"
def test_bogus_interface_name_never_reaches_the_guest(guest, monkeypatch):
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
result = guest_ip_service.sync_interface(
guest["db"], guest["server"].id, 120, "eth0; rm -rf /")
assert not result.success and result.status == guest_ip_service.FAILED
assert recorder.calls == []
def test_primary_address_is_not_pushed_into_the_guest(guest, monkeypatch):
"""Основной адрес задаётся конфигом Proxmox — скрипт для него не нужен."""
allocation, _ = guest["service"].allocate_ip(
ip_address="10.10.10.5", network_id=guest["network"].id,
proxmox_server_id=guest["server"].id, proxmox_vmid=120,
allocated_by="tester", is_primary=True, assignment_kind="primary",
)
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
result = guest_ip_service.apply_address(guest["db"], allocation)
assert result.success
assert recorder.calls == []
def test_unknown_guest_is_pending_not_crash(guest, monkeypatch):
allocation = guest["add_alias"]("10.10.10.90")
guest["instance"].deleted_at = __import__("app.config", fromlist=["utcnow"]).utcnow()
guest["db"].commit()
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
result = guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth0")
assert result.status == guest_ip_service.PENDING
assert recorder.calls == []

View File

@@ -0,0 +1,178 @@
"""Tests for IPAM link-allocations: MAC propagation and duplicate-IP handling."""
import ipaddress
import pytest
from app.api.ipam import _extract_ipv4s
from app.ipam_service import IPAMService
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
# --- _extract_ipv4s -----------------------------------------------------------
HOME_NET = ipaddress.ip_network("10.10.10.0/24")
class _Net:
"""Stand-in for an IPAMNetwork row (only .id is read by the caller)."""
def __init__(self, id_=1):
self.id = id_
def test_extract_returns_mac_and_interface_of_matching_nic():
interfaces = [
{"name": "eth0", "hardware_address": "bc:24:11:aa:bb:cc",
"ips": [{"address": "10.10.10.9", "type": "ipv4", "prefix": 24}]},
]
found = _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])
assert len(found) == 1
assert (found[0].ip, found[0].mac, found[0].interface) == (
"10.10.10.9", "BC:24:11:AA:BB:CC", "eth0")
def test_extract_returns_every_address_in_ipam_networks():
"""Гость с несколькими адресами отдаёт их все, а не только первый."""
interfaces = [
{"name": "eth0", "hardware_address": "AA:AA:AA:AA:AA:AA",
"ips": [{"address": "192.168.99.5", "type": "ipv4", "prefix": 24},
{"address": "10.10.10.9", "type": "ipv4", "prefix": 24},
{"address": "10.10.10.90", "type": "ipv4", "prefix": 24}]},
{"name": "eth1", "hardware_address": "BB:BB:BB:BB:BB:BB",
"ips": [{"address": "10.10.10.44", "type": "ipv4", "prefix": 24}]},
]
found = _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])
assert [f.ip for f in found] == ["10.10.10.9", "10.10.10.90", "10.10.10.44"]
assert [f.interface for f in found] == ["eth0", "eth0", "eth1"]
assert found[-1].mac == "BB:BB:BB:BB:BB:BB"
def test_extract_skips_loopback_and_link_local():
interfaces = [{"name": "eth0", "ips": [
{"address": "127.0.0.1", "type": "ipv4", "prefix": 8},
{"address": "169.254.1.5", "type": "ipv4", "prefix": 16},
{"address": "10.10.10.9", "type": "ipv4", "prefix": 24},
]}]
assert [f.ip for f in _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])] == ["10.10.10.9"]
def test_extract_without_mac_returns_none():
interfaces = [{"name": "eth0", "ips": [{"address": "10.10.10.9", "type": "ipv4", "prefix": 24}]}]
assert _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])[0].mac is None
def test_extract_no_match_returns_empty_list():
interfaces = [{"name": "eth0", "hardware_address": "AA:BB:CC:DD:EE:FF",
"ips": [{"address": "192.168.43.222", "type": "ipv4", "prefix": 24}]}]
assert _extract_ipv4s(interfaces, [(_Net(), HOME_NET)]) == []
# --- sync_from_proxmox_vm -----------------------------------------------------
@pytest.fixture
def ipam_env(db_session):
"""A server, a network and a helper to add guests."""
server = ProxmoxServer(name="Homelab: Dev", ip_address="10.10.10.2", hostname="pve1",
api_user="root@pam", use_password=True, password="x")
db_session.add(server)
db_session.commit()
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24", gateway="10.10.10.1",
is_active=True)
db_session.add(network)
db_session.commit()
def add_guest(vmid, name, vm_type="lxc", deleted_at=None):
guest = VMInstance(server_id=server.id, vmid=vmid, node="pve1", vm_type=vm_type,
name=name, status="running", deleted_at=deleted_at)
db_session.add(guest)
db_session.commit()
return guest
return {"db": db_session, "server": server, "network": network, "add_guest": add_guest,
"service": IPAMService(db_session)}
def _sync(env, vmid, name, ip, mac=None):
return env["service"].sync_from_proxmox_vm(
network_id=env["network"].id,
proxmox_server_id=env["server"].id,
vmid=vmid,
vm_name=name,
vm_type="lxc",
ip_address=ip,
node="pve1",
mac_address=mac,
synced_by="tester",
)
def test_sync_stores_mac_on_new_allocation(ipam_env):
ipam_env["add_guest"](109, "gitea")
alloc, error = _sync(ipam_env, 109, "gitea", "10.10.10.17", "BC:24:11:AA:BB:CC")
assert error is None
assert alloc.mac_address == "BC:24:11:AA:BB:CC"
assert alloc.proxmox_vmid == 109
def test_duplicate_ip_does_not_steal_allocation_from_a_live_guest(ipam_env):
"""The 10.10.10.101 case: two live guests advertising the same address."""
ipam_env["add_guest"](101, "proxmox-center")
ipam_env["add_guest"](104, "powerdns-admin")
first, error = _sync(ipam_env, 101, "proxmox-center", "10.10.10.101")
assert error is None and first is not None
second, error = _sync(ipam_env, 104, "powerdns-admin", "10.10.10.101")
assert second is None
assert "already linked to proxmox-center" in error
assert "VMID 101" in error
# Owner untouched.
alloc = ipam_env["db"].query(IPAMAllocation).filter(
IPAMAllocation.ip_address == "10.10.10.101").one()
assert (alloc.proxmox_vmid, alloc.resource_name) == (101, "proxmox-center")
def test_allocation_of_a_vanished_guest_is_reclaimed(ipam_env):
"""A guest that no longer exists must not hold the address hostage."""
ipam_env["add_guest"](101, "old-guest", deleted_at=None)
_sync(ipam_env, 101, "old-guest", "10.10.10.101")
# Guest disappears from Proxmox (soft-deleted by the sync worker).
guest = ipam_env["db"].query(VMInstance).filter(VMInstance.vmid == 101).one()
from app.config import utcnow
guest.deleted_at = utcnow()
ipam_env["db"].commit()
ipam_env["add_guest"](104, "powerdns-admin")
alloc, error = _sync(ipam_env, 104, "powerdns-admin", "10.10.10.101")
assert error is None
assert (alloc.proxmox_vmid, alloc.resource_name) == (104, "powerdns-admin")
def test_manual_allocation_without_vmid_is_adopted(ipam_env):
"""A hand-made record with no Proxmox link is still linkable."""
ipam_env["add_guest"](110, "postgresql")
manual, error = ipam_env["service"].allocate_ip(
ip_address="10.10.10.19", network_id=ipam_env["network"].id,
resource_name="reserved by hand", allocated_by="admin")
assert error is None and manual.proxmox_vmid is None
alloc, error = _sync(ipam_env, 110, "postgresql", "10.10.10.19", "AA:BB:CC:DD:EE:FF")
assert error is None
assert alloc.proxmox_vmid == 110
def test_resync_of_the_same_guest_is_idempotent(ipam_env):
ipam_env["add_guest"](115, "termix")
first, _ = _sync(ipam_env, 115, "termix", "10.10.10.36", "AA:11:22:33:44:55")
again, error = _sync(ipam_env, 115, "termix", "10.10.10.36", "AA:11:22:33:44:55")
assert error is None
assert again.id == first.id
assert ipam_env["db"].query(IPAMAllocation).count() == 1

View File

@@ -0,0 +1,257 @@
"""Несколько IP-адресов на одном инстансе: сервис, API и колонка IP."""
import pytest
from app.ipam_service import IPAMService
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
from app.services import guest_ip_service, script_engine
OK_OUTPUT = "PVEMANAGER_PERSIST=ifupdown\nPVEMANAGER_OK=1\n"
@pytest.fixture
def env(db_session, seed_users):
server = ProxmoxServer(name="Homelab: Dev", ip_address="10.10.10.2", hostname="dev",
api_user="root@pam", use_password=True, password="pw",
is_online=True)
db_session.add(server)
db_session.commit()
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24", gateway="10.10.10.1",
is_active=True)
db_session.add(network)
db_session.commit()
instance = VMInstance(server_id=server.id, vmid=109, node="dev", vm_type="lxc",
name="gitea", status="running")
db_session.add(instance)
db_session.commit()
service = IPAMService(db_session)
def allocate(ip, primary=False, kind="alias", interface="eth0"):
allocation, error = service.allocate_ip(
ip_address=ip, network_id=network.id, resource_type="lxc", resource_name="gitea",
proxmox_server_id=server.id, proxmox_vmid=109, proxmox_node="dev",
allocated_by="tester", is_primary=primary, assignment_kind=kind,
target_interface=interface,
)
assert error is None, error
return allocation
return {"db": db_session, "server": server, "network": network, "instance": instance,
"service": service, "allocate": allocate}
@pytest.fixture(autouse=True)
def no_real_guest(monkeypatch):
"""Скрипт в гостя не уезжает — транспорт проверяется отдельным тестом."""
monkeypatch.setattr(
guest_ip_service.script_engine, "execute",
lambda server, **kwargs: script_engine.ExecResult(True, OK_OUTPUT, 0),
)
# --- сервис -------------------------------------------------------------------
def test_guest_can_hold_several_addresses(env):
env["allocate"]("10.10.10.17", primary=True, kind="primary")
env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.91")
rows = env["service"].find_allocations_by_resource(env["server"].id, 109)
assert [r.ip_address for r in rows] == ["10.10.10.17", "10.10.10.90", "10.10.10.91"]
assert rows[0].is_primary is True
def test_primary_lookup_returns_the_flagged_address(env):
env["allocate"]("10.10.10.90")
primary = env["allocate"]("10.10.10.17", primary=True, kind="primary")
found = env["service"].find_allocation_by_resource(env["server"].id, 109)
assert found.id == primary.id
def test_lookup_falls_back_to_earliest_without_flag(env):
"""Записи старше миграции multi-IP флага не имеют — берём самую раннюю."""
first = env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.91")
found = env["service"].find_allocation_by_resource(env["server"].id, 109)
assert found.id == first.id
def test_set_primary_leaves_exactly_one_primary(env):
old = env["allocate"]("10.10.10.17", primary=True, kind="primary")
new = env["allocate"]("10.10.10.90")
updated, error = env["service"].set_primary(env["server"].id, 109, new.id, changed_by="tester")
assert error is None and updated.id == new.id
env["db"].refresh(old)
assert [a.is_primary for a in env["service"].find_allocations_by_resource(env["server"].id, 109)] \
== [True, False]
assert old.is_primary is False
def test_set_primary_rejects_foreign_allocation(env):
other = env["service"].allocate_ip(
ip_address="10.10.10.50", network_id=env["network"].id,
proxmox_server_id=env["server"].id, proxmox_vmid=999, allocated_by="tester")[0]
updated, error = env["service"].set_primary(env["server"].id, 109, other.id)
assert updated is None and "does not belong" in error
def test_deleting_a_guest_releases_every_address(env):
env["allocate"]("10.10.10.17", primary=True, kind="primary")
env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.91")
released, ips = env["service"].release_ip_by_vmid(env["server"].id, 109, released_by="tester")
assert released is True
assert "10.10.10.17" in ips and "10.10.10.91" in ips
assert env["db"].query(IPAMAllocation).count() == 0
# --- API ----------------------------------------------------------------------
def test_list_addresses_returns_primary_first(env, client, admin_headers):
env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.17", primary=True, kind="primary")
response = client.get(
f"/ipam/api/guests/{env['server'].id}/109/addresses", headers=admin_headers)
assert response.status_code == 200
body = response.json()
assert body["count"] == 2
assert body["addresses"][0]["ip_address"] == "10.10.10.17"
assert body["addresses"][0]["is_primary"] is True
assert body["addresses"][0]["prefix"] == "24"
def test_add_address_allocates_and_applies(env, client, admin_headers):
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
"target_interface": "eth0"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["applied"] is True
assert body["apply_status"] == guest_ip_service.APPLIED
assert body["address"]["assignment_kind"] == "alias"
stored = env["db"].query(IPAMAllocation).filter(
IPAMAllocation.ip_address == "10.10.10.90").one()
assert stored.proxmox_vmid == 109
assert stored.target_interface == "eth0"
def test_add_address_picks_next_free_ip(env, client, admin_headers):
env["allocate"]("10.10.10.2", primary=True, kind="primary")
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "target_interface": "eth0"},
)
assert response.status_code == 200, response.text
assigned = response.json()["address"]["ip_address"]
assert assigned.startswith("10.10.10.")
assert assigned not in ("10.10.10.1", "10.10.10.2") # шлюз и занятый адрес
def test_add_address_can_take_over_as_primary(env, client, admin_headers):
old = env["allocate"]("10.10.10.17", primary=True, kind="primary")
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
"target_interface": "eth0", "make_primary": True},
)
assert response.status_code == 200, response.text
env["db"].refresh(old)
env["db"].refresh(env["instance"])
assert old.is_primary is False
# Кэш инстанса показывает новый основной адрес
assert env["instance"].ip_address == "10.10.10.90"
def test_duplicate_address_is_rejected(env, client, admin_headers):
env["allocate"]("10.10.10.90")
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
"target_interface": "eth0"},
)
assert response.status_code == 400
assert "already allocated" in response.json()["detail"]
def test_release_address_frees_it(env, client, admin_headers):
alias = env["allocate"]("10.10.10.90")
response = client.delete(
f"/ipam/api/guests/{env['server'].id}/109/addresses/{alias.id}", headers=admin_headers)
assert response.status_code == 200, response.text
assert response.json()["released"] == "10.10.10.90"
assert env["db"].query(IPAMAllocation).filter(
IPAMAllocation.ip_address == "10.10.10.90").first() is None
def test_primary_address_cannot_be_released_here(env, client, admin_headers):
primary = env["allocate"]("10.10.10.17", primary=True, kind="primary")
response = client.delete(
f"/ipam/api/guests/{env['server'].id}/109/addresses/{primary.id}", headers=admin_headers)
assert response.status_code == 400
assert "NIC config" in response.json()["detail"]
assert env["db"].query(IPAMAllocation).count() == 1
def test_address_of_another_guest_is_not_accessible(env, client, admin_headers):
foreign = env["service"].allocate_ip(
ip_address="10.10.10.50", network_id=env["network"].id,
proxmox_server_id=env["server"].id, proxmox_vmid=999, allocated_by="tester")[0]
response = client.delete(
f"/ipam/api/guests/{env['server'].id}/109/addresses/{foreign.id}", headers=admin_headers)
assert response.status_code == 404
def test_instance_list_exposes_all_addresses(env, client, admin_headers):
env["allocate"]("10.10.10.17", primary=True, kind="primary")
env["allocate"]("10.10.10.90")
response = client.get("/proxmox/api/virtual-machines", headers=admin_headers)
assert response.status_code == 200
vm = next(v for v in response.json() if v["vmid"] == 109)
assert vm["ip"] == "10.10.10.17" # колонка IP показывает основной
assert [a["ip"] for a in vm["ips"]] == ["10.10.10.17", "10.10.10.90"]
assert [a["is_primary"] for a in vm["ips"]] == [True, False]
def test_network_scan_skips_node_and_cached_guest_ips(env):
"""Скан вне пула не должен предлагать адрес ноды или известного гостя."""
# Нода Homelab: Dev живёт на 10.10.10.2, гость gitea — на 10.10.10.3
env["instance"].ip_address = "10.10.10.3"
env["db"].commit()
ip = env["service"].get_next_available_ip(
env["network"].id, None, allow_network_scan=True)
assert ip not in ("10.10.10.1", "10.10.10.2", "10.10.10.3")

View File

@@ -0,0 +1,74 @@
"""IP shown for a guest must come from its own IPAM allocation, never from a
same-named guest on another server."""
import pytest
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
@pytest.mark.integration
class TestVMListIPAMLookup:
@pytest.fixture(autouse=True)
def setup(self, db_session, seed_users):
def _server(name, ip):
server = ProxmoxServer(name=name, hostname=name, ip_address=ip, port=8006,
api_user="root@pam", use_password=True, password="pw",
verify_ssl=False, is_online=False)
db_session.add(server)
db_session.commit()
db_session.refresh(server)
return server
# Two distinct servers, each with its own node that happens to share a name.
self.server_a = _server("homelab-dev", "10.10.10.2")
self.server_b = _server("devserver", "192.168.43.254")
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24",
gateway="10.10.10.1", is_active=True)
db_session.add(network)
db_session.commit()
# Same guest name on both servers — the real-world "wireguard" case.
self.linked = VMInstance(server_id=self.server_a.id, vmid=107, node="dev",
vm_type="lxc", name="wireguard", status="running")
self.other = VMInstance(server_id=self.server_b.id, vmid=102, node="dev",
vm_type="lxc", name="wireguard", status="running")
db_session.add_all([self.linked, self.other])
db_session.commit()
db_session.add(IPAMAllocation(
ip_address="10.10.10.35", network_id=network.id, status="allocated",
resource_type="lxc", resource_name="wireguard",
proxmox_server_id=self.server_a.id, proxmox_vmid=107, proxmox_node="dev",
))
db_session.commit()
self.db = db_session
def _instances(self, client, headers):
response = client.get("/proxmox/api/virtual-machines", headers=headers)
assert response.status_code == 200
return {(vm["server_id"], vm["vmid"]): vm for vm in response.json()}
def test_allocation_is_not_shared_with_a_same_named_guest(self, client, admin_headers):
vms = self._instances(client, admin_headers)
assert vms[(self.server_a.id, 107)]["ip"] == "10.10.10.35"
# The namesake on the other server owns no allocation and has no cached IP.
assert vms[(self.server_b.id, 102)]["ip"] == ""
def test_cached_ip_still_wins_when_no_allocation_exists(self, client, admin_headers):
self.other.ip_address = "192.168.43.246"
self.db.commit()
vms = self._instances(client, admin_headers)
assert vms[(self.server_b.id, 102)]["ip"] == "192.168.43.246"
assert vms[(self.server_a.id, 107)]["ip"] == "10.10.10.35"
def test_allocation_hostname_does_not_leak_either(self, client, admin_headers):
"""A hostname on the allocation used to match guests by name as well."""
alloc = self.db.query(IPAMAllocation).one()
alloc.hostname = "wireguard"
self.db.commit()
vms = self._instances(client, admin_headers)
assert vms[(self.server_b.id, 102)]["ip"] == ""

View File

@@ -0,0 +1,235 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Globe } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useIPAMNetworks, useIPAMPools, useAddGuestAddress } from '@/hooks/use-ipam';
import { toast } from 'sonner';
const IP_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
interface Props {
open: boolean;
onClose: () => void;
serverId: number;
vmid: number;
/** Имена интерфейсов гостя, среди которых можно выбрать целевой */
interfaces: string[];
}
export default function AddGuestIPDialog({ open, onClose, serverId, vmid, interfaces }: Props) {
const { t } = useTranslation();
// Диалог монтируется только на время показа (см. NetworkingTab), поэтому
// состояние достаточно задать начальными значениями — сбрасывать эффектом
// при каждом открытии не нужно.
const [networkId, setNetworkId] = useState('');
const [poolId, setPoolId] = useState('');
const [mode, setMode] = useState<'auto' | 'manual'>('auto');
const [ip, setIp] = useState('');
const [iface, setIface] = useState(interfaces[0] ?? '');
const [makePrimary, setMakePrimary] = useState(false);
const { data: networks } = useIPAMNetworks();
const { data: pools } = useIPAMPools(networkId ? Number(networkId) : undefined);
const addAddress = useAddGuestAddress(serverId, vmid);
const activeNetworks = useMemo(
() => (networks ?? []).filter((n) => n.is_active !== false),
[networks],
);
const submit = () => {
if (!networkId) {
toast.error(t('ipam.err_network_required', 'Выберите сеть'));
return;
}
if (mode === 'manual' && !IP_RE.test(ip)) {
toast.error(t('ipam.err_bad_ip', 'Некорректный IP-адрес'));
return;
}
addAddress.mutate(
{
network_id: Number(networkId),
pool_id: poolId ? Number(poolId) : undefined,
ip_address: mode === 'manual' ? ip : undefined,
target_interface: iface || undefined,
make_primary: makePrimary,
},
{
onSuccess: (res) => {
const address = res.address?.ip_address ?? '';
if (res.applied) {
if (res.apply_status === 'runtime_only') {
toast.warning(
t('ipam.address_runtime_only', {
defaultValue: '{{ip}} поднят, но не закреплён: стек сети гостя не распознан',
ip: address,
}),
);
} else {
toast.success(
t('ipam.address_added', { defaultValue: 'Адрес {{ip}} назначен', ip: address }),
);
}
} else {
// Адрес всё равно зарезервирован — применить можно позже
toast.warning(
t('ipam.address_reserved_not_applied', {
defaultValue: '{{ip}} зарезервирован, но не применён: {{error}}',
ip: address,
error: res.error ?? '',
}),
);
}
onClose();
},
onError: (e) => toast.error((e as Error).message),
},
);
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Globe className="h-4 w-4" />
{t('ipam.add_address_title', 'Добавить IP-адрес')}
</DialogTitle>
<DialogDescription>
{t(
'ipam.add_address_description',
'Адрес выделяется в IPAM и навешивается на интерфейс гостя дополнительным (alias).',
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{t('ipam.network', 'Сеть')}</Label>
<Select value={networkId} onValueChange={(v) => { setNetworkId(v ?? ''); setPoolId(''); }}>
<SelectTrigger>
<SelectValue placeholder={t('ipam.select_network', 'Выберите сеть')} />
</SelectTrigger>
<SelectContent>
{activeNetworks.map((n) => (
<SelectItem key={n.id} value={String(n.id)}>
<span className="flex items-center gap-2">
{n.name}
<span className="text-xs text-muted-foreground">{n.network}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t('ipam.pool', 'Пул')}</Label>
<Select value={poolId} onValueChange={(v) => setPoolId(v ?? '')} disabled={!networkId}>
<SelectTrigger>
<SelectValue placeholder={t('ipam.any_pool', 'Любой')} />
</SelectTrigger>
<SelectContent>
{(pools ?? []).map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
<span className="flex items-center gap-2">
{p.name}
<span className="text-xs text-muted-foreground">
{p.range_start} – {p.range_end}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{t('ipam.address_mode', 'Адрес')}</Label>
<Select value={mode} onValueChange={(v) => setMode(v as 'auto' | 'manual')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('ipam.next_free', 'Следующий свободный')}</SelectItem>
<SelectItem value="manual">{t('ipam.manual_ip', 'Указать вручную')}</SelectItem>
</SelectContent>
</Select>
</div>
{mode === 'manual' && (
<div className="space-y-1.5">
<Label htmlFor="guest-ip">IP</Label>
<Input
id="guest-ip"
value={ip}
onChange={(e) => setIp(e.target.value)}
placeholder="10.10.10.90"
className="font-mono"
/>
</div>
)}
</div>
<div className="space-y-1.5">
<Label>{t('ipam.target_interface', 'Интерфейс гостя')}</Label>
{interfaces.length > 0 ? (
<Select value={iface} onValueChange={(v) => setIface(v ?? '')}>
<SelectTrigger>
<SelectValue placeholder="eth0" />
</SelectTrigger>
<SelectContent>
{interfaces.map((name) => (
<SelectItem key={name} value={name}>{name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={iface}
onChange={(e) => setIface(e.target.value)}
placeholder="eth0"
className="font-mono"
/>
)}
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={makePrimary} onChange={(e) => setMakePrimary(e.target.checked)} />
{t('ipam.make_primary', 'Сделать основным адресом')}
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
<Button onClick={submit} disabled={addAddress.isPending || !networkId}>
{t('ipam.add_address_submit', 'Выдать адрес')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1031,16 +1031,33 @@ export default function InstancesPage() {
},
{
id: 'ip',
accessorKey: 'ip_address',
// Поиск идёт по всем адресам гостя, а показывается основной
accessorFn: (vm) => [vm.ip_address, ...(vm.ips ?? []).map((a) => a.ip)]
.filter(Boolean)
.join(' '),
filterFn: 'includesString',
meta: { filter: 'text' },
header: 'IP',
cell: ({ getValue }) => {
const ip = getValue<string>();
return ip ? (
<span className="font-mono text-xs">{ip}</span>
) : (
<span className="text-xs text-muted-foreground">—</span>
cell: ({ row }) => {
const vm = row.original;
const primary = vm.ip_address;
const extra = (vm.ips ?? []).filter((a) => a.ip !== primary);
if (!primary) {
return <span className="text-xs text-muted-foreground">—</span>;
}
return (
<span className="flex items-center gap-1.5">
<span className="font-mono text-xs">{primary}</span>
{extra.length > 0 && (
<Badge
variant="secondary"
className="text-2xs"
title={extra.map((a) => a.ip).join(', ')}
>
{t('ipam.extra_addresses', { defaultValue: 'ещё {{count}}', count: extra.length })}
</Badge>
)}
</span>
);
},
size: 130,

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Globe, Wifi, Plus, Trash2 } from 'lucide-react';
import { Globe, Wifi, Plus, Trash2, Star, RefreshCw } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -13,9 +13,17 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { useVMInterfaces, useVMConfig, useVMStatus, useUpdateConfig } from '@/hooks/use-instances';
import {
useGuestAddresses,
useApplyGuestAddress,
useSetPrimaryGuestAddress,
useRemoveGuestAddress,
} from '@/hooks/use-ipam';
import type { GuestAddress } from '@/types';
import { formatBytes } from '@/lib/format';
import { toast } from 'sonner';
import AddNetworkInterfaceDialog from '../AddNetworkInterfaceDialog';
import AddGuestIPDialog from '../AddGuestIPDialog';
interface Props {
serverId: number;
@@ -41,6 +49,23 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
.sort(([a], [b]) => a.localeCompare(b))
: [];
// Имена интерфейсов гостя для выбора цели alias-адреса: сперва то, что
// реально видно в госте, иначе имена из конфига (LXC пишет name=ethN).
const guestInterfaces = (() => {
const live = (ifaces?.interfaces ?? [])
.map((i) => i.name)
.filter((name): name is string => !!name && name !== 'lo');
if (live.length > 0) return live;
return netDevices
.map(([, value]) =>
String(value)
.split(',')
.find((part) => part.startsWith('name='))
?.slice(5),
)
.filter((name): name is string => !!name);
})();
const removeNic = () => {
if (!deleteTarget) return;
updateConfig.mutate(
@@ -73,6 +98,9 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
</Card>
</div>
{/* IPAM addresses of this guest */}
<GuestAddressesCard serverId={serverId} vmid={vmid} interfaces={guestInterfaces} />
{/* Configured network devices */}
<Card>
<CardHeader className="pb-2">
@@ -199,3 +227,213 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
</div>
);
}
function GuestAddressesCard({ serverId, vmid, interfaces }: {
serverId: number;
vmid: number;
interfaces: string[];
}) {
const { t } = useTranslation();
const { data, isLoading } = useGuestAddresses(serverId, vmid);
const applyAddress = useApplyGuestAddress(serverId, vmid);
const setPrimary = useSetPrimaryGuestAddress(serverId, vmid);
const removeAddress = useRemoveGuestAddress(serverId, vmid);
const [showAdd, setShowAdd] = useState(false);
const [releaseTarget, setReleaseTarget] = useState<GuestAddress | null>(null);
const addresses = data?.addresses ?? [];
// Статус применения alias-адреса: applied — закреплён в конфиге гостя,
// runtime_only — поднят, но не переживёт перезагрузку, pending/failed —
// адрес зарезервирован в IPAM, но на гостя не попал.
const statusBadge = (address: GuestAddress) => {
if (address.assignment_kind !== 'alias') {
return <Badge variant="secondary" className="text-xs">{t('ipam.from_nic_config', 'из конфига NIC')}</Badge>;
}
switch (address.apply_status) {
case 'applied':
return <Badge variant="secondary" className="text-xs">{t('ipam.applied', 'применён')}</Badge>;
case 'runtime_only':
return (
<Badge
variant="outline"
className="text-xs"
title={t('ipam.runtime_only_hint', 'Адрес поднят, но стек сети гостя не распознан — после перезагрузки исчезнет')}
>
{t('ipam.runtime_only', 'до перезагрузки')}
</Badge>
);
case 'pending':
return (
<Badge variant="outline" className="text-xs" title={address.apply_error ?? undefined}>
{t('ipam.not_applied', 'не применён')}
</Badge>
);
case 'failed':
return (
<Badge variant="destructive" className="text-xs" title={address.apply_error ?? undefined}>
{t('common.error', 'Ошибка')}
</Badge>
);
default:
return <Badge variant="outline" className="text-xs">{t('ipam.discovered', 'обнаружен')}</Badge>;
}
};
const release = () => {
if (!releaseTarget) return;
removeAddress.mutate(releaseTarget.id, {
onSuccess: (res) => {
if (res.removed_from_guest) {
toast.success(t('ipam.address_released', { defaultValue: 'Адрес {{ip}} освобождён', ip: res.released }));
} else {
toast.warning(
t('ipam.address_released_not_removed', {
defaultValue: '{{ip}} освобождён в IPAM, но снять его с гостя не удалось: {{error}}',
ip: res.released,
error: res.error ?? '',
}),
);
}
setReleaseTarget(null);
},
onError: (e) => toast.error((e as Error).message),
});
};
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Globe className="h-4 w-4" />
{t('ipam.addresses', 'IP-адреса')}
{addresses.length > 0 && <Badge variant="secondary">{addresses.length}</Badge>}
</CardTitle>
<Button size="sm" variant="outline" onClick={() => setShowAdd(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t('ipam.add_address', 'Добавить IP')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2">
{isLoading ? (
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка...')}</p>
) : addresses.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{t('ipam.no_addresses', 'Адресов в IPAM нет')}
</p>
) : (
addresses.map((address) => (
<div key={address.id} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">
{address.ip_address}
{address.prefix ? `/${address.prefix}` : ''}
</span>
{address.is_primary && (
<Badge className="text-xs">{t('ipam.primary', 'основной')}</Badge>
)}
{statusBadge(address)}
{address.target_interface && (
<Badge variant="outline" className="text-xs">{address.target_interface}</Badge>
)}
</div>
<div className="flex items-center gap-1">
{address.assignment_kind === 'alias' && address.apply_status !== 'applied' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={applyAddress.isPending}
title={t('ipam.apply', 'Применить')}
onClick={() =>
applyAddress.mutate(address.id, {
onSuccess: (res) =>
res.applied
? toast.success(t('ipam.applied', 'применён'))
: toast.error(res.error ?? t('common.error', 'Ошибка')),
onError: (e) => toast.error((e as Error).message),
})
}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
)}
{!address.is_primary && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={setPrimary.isPending}
title={t('ipam.make_primary', 'Сделать основным адресом')}
onClick={() =>
setPrimary.mutate(address.id, {
onError: (e) => toast.error((e as Error).message),
})
}
>
<Star className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
disabled={address.assignment_kind !== 'alias'}
title={
address.assignment_kind === 'alias'
? t('ipam.release', 'Освободить')
: t('ipam.primary_managed_by_nic', 'Основной адрес задаётся конфигом интерфейса')
}
onClick={() => setReleaseTarget(address)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{address.apply_error && (
<p className="mt-1 text-xs text-muted-foreground">{address.apply_error}</p>
)}
</div>
))
)}
</CardContent>
{showAdd && (
<AddGuestIPDialog
open
onClose={() => setShowAdd(false)}
serverId={serverId}
vmid={vmid}
interfaces={interfaces}
/>
)}
<Dialog open={!!releaseTarget} onOpenChange={(o) => !o && setReleaseTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('ipam.release_title', 'Освободить адрес?')}</DialogTitle>
<DialogDescription>
{t('ipam.release_description', {
defaultValue:
'Адрес {{ip}} будет снят с интерфейса гостя и освобождён в IPAM.',
ip: releaseTarget?.ip_address ?? '',
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setReleaseTarget(null)}>
{t('common.cancel', 'Отмена')}
</Button>
<Button variant="destructive" onClick={release} disabled={removeAddress.isPending}>
{t('ipam.release', 'Освободить')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -44,7 +44,9 @@ export default function IPAMAllocationsPage() {
a.ip_address?.toLowerCase().includes(s) ||
a.resource_name?.toLowerCase().includes(s) ||
a.mac_address?.toLowerCase().includes(s) ||
a.description?.toLowerCase().includes(s)
a.description?.toLowerCase().includes(s) ||
// поиск по VMID, чтобы собрать все адреса одного гостя
String(a.proxmox_vmid ?? '').includes(s)
);
}
return true;
@@ -108,12 +110,25 @@ export default function IPAMAllocationsPage() {
<TableBody>
{filtered.map(a => (
<TableRow key={a.id}>
<TableCell className="font-mono">{a.ip_address}</TableCell>
<TableCell className="font-mono">
<span className="flex items-center gap-1.5">
{a.ip_address}
{/* У гостя может быть несколько адресов — помечаем основной */}
{a.is_primary && (
<Badge variant="secondary" className="text-2xs">{t('ipam.primary')}</Badge>
)}
</span>
</TableCell>
<TableCell>
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
</TableCell>
<TableCell className="text-xs">{a.network_name || '—'}</TableCell>
<TableCell className="text-xs">{a.resource_name || '—'}</TableCell>
<TableCell className="text-xs">
{a.resource_name || '—'}
{a.proxmox_vmid != null && (
<span className="ml-1 text-muted-foreground">#{a.proxmox_vmid}</span>
)}
</TableCell>
<TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">{a.description || '—'}</TableCell>
<TableCell>

View File

@@ -235,7 +235,20 @@ function UnlinkedCard() {
const handleLink = () => {
link.mutate(undefined, {
onSuccess: (r) => toast.success(`${t('ipam.linked')}: ${r.linked?.length ?? 0}`),
onSuccess: (r) => {
toast.success(`${t('ipam.linked')}: ${r.linked?.length ?? 0}`);
// Пропуски (дубль IP, недоступный сервер, адрес вне сетей IPAM) иначе
// видны только в логах бэкенда — показываем причины первых записей.
const skipped = r.not_found ?? [];
if (skipped.length > 0) {
toast.warning(t('ipam.link_skipped', { count: skipped.length }), {
description: skipped
.slice(0, 3)
.map((s) => `${s.resource_name}: ${s.reason}`)
.join('\n'),
});
}
},
onError: (e: Error) => toast.error(e.message),
});
};

View File

@@ -1,6 +1,12 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { IPAMNetwork, IPAMPool, IPAMAllocation } from '@/types';
import type {
IPAMNetwork,
IPAMPool,
IPAMAllocation,
GuestAddress,
GuestAddressApplyStatus,
} from '@/types';
export const ipamKeys = {
networks: ['ipam-networks'] as const,
@@ -251,11 +257,17 @@ export function useCleanupOrphans() {
});
}
export interface IPAMLinkSkipped {
ip_address: string;
resource_name: string;
reason: string;
}
export function useLinkAllocations() {
const invalidate = useInvalidateIPAM();
return useMutation({
mutationFn: (body?: { server_id?: number; network_id?: number }) =>
apiClient.post<{ linked: unknown[]; not_found: unknown[] }>('/ipam/api/link-allocations', body ?? {}),
apiClient.post<{ linked: unknown[]; not_found: IPAMLinkSkipped[] }>('/ipam/api/link-allocations', body ?? {}),
onSuccess: () => invalidate(),
});
}
@@ -283,3 +295,95 @@ export function useIPHistoryLookup() {
mutationFn: (ip: string) => apiClient.get<unknown[]>(`/ipam/api/history/ip/${encodeURIComponent(ip)}`),
});
}
// ==================== Адреса инстанса (несколько IP на гостя) ====================
export interface GuestAddressApplyResult {
address: GuestAddress;
applied: boolean;
apply_status: GuestAddressApplyStatus;
persist?: string | null;
error?: string | null;
}
export const guestAddressKey = (serverId: number, vmid: number) =>
['guest-addresses', serverId, vmid] as const;
export function useGuestAddresses(serverId: number, vmid: number, enabled = true) {
return useQuery({
queryKey: guestAddressKey(serverId, vmid),
queryFn: () =>
apiClient.get<{ addresses: GuestAddress[]; count: number }>(
`/ipam/api/guests/${serverId}/${vmid}/addresses`,
),
enabled: enabled && serverId > 0 && vmid > 0,
});
}
/** Инвалидация адресов гостя + всего, что показывает IP инстанса. */
function useInvalidateGuestAddresses(serverId: number, vmid: number) {
const qc = useQueryClient();
const invalidateIPAM = useInvalidateIPAM();
return () => {
qc.invalidateQueries({ queryKey: guestAddressKey(serverId, vmid) });
qc.invalidateQueries({ queryKey: ['instances'] });
qc.invalidateQueries({ queryKey: ['vms'] });
invalidateIPAM();
};
}
export function useAddGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (body: {
network_id: number;
pool_id?: number;
ip_address?: string;
target_interface?: string;
make_primary?: boolean;
hostname?: string;
notes?: string;
}) =>
apiClient.post<GuestAddressApplyResult>(
`/ipam/api/guests/${serverId}/${vmid}/addresses`,
body,
),
onSuccess: () => invalidate(),
});
}
export function useApplyGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (allocationId: number) =>
apiClient.post<GuestAddressApplyResult>(
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}/apply`,
{},
),
onSuccess: () => invalidate(),
});
}
export function useSetPrimaryGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (allocationId: number) =>
apiClient.post<{ address: GuestAddress }>(
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}/primary`,
{},
),
onSuccess: () => invalidate(),
});
}
export function useRemoveGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (allocationId: number) =>
apiClient.delete<{ released: string; removed_from_guest: boolean; error?: string | null }>(
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}`,
),
onSuccess: () => invalidate(),
});
}

View File

@@ -1419,6 +1419,7 @@
"no_unlinked": "All VMs are linked",
"link_allocations": "Scan & link",
"linked": "Linked",
"link_skipped": "Skipped: {{count}}",
"ip_history": "IP history lookup",
"pool_required_hint": "Auto-allocation requires an auto-assign pool in the network.",
"resource_required": "Please specify a resource before allocating an IP.",
@@ -1483,7 +1484,40 @@
"allocation_created": "Allocation created",
"allocation_updated": "Allocation updated",
"delete_allocation_confirm": "Release IP {{ip}}?",
"notes": "Notes"
"notes": "Notes",
"addresses": "IP addresses",
"add_address": "Add IP",
"add_address_title": "Add IP address",
"add_address_description": "The address is allocated in IPAM and attached to the guest interface as an alias.",
"add_address_submit": "Assign address",
"no_addresses": "No addresses in IPAM",
"primary": "primary",
"make_primary": "Make primary address",
"primary_managed_by_nic": "The primary address comes from the NIC config",
"from_nic_config": "from NIC config",
"applied": "applied",
"not_applied": "not applied",
"runtime_only": "until reboot",
"runtime_only_hint": "Address is up, but the guest network stack was not recognised — it will not survive a reboot",
"discovered": "discovered",
"apply": "Apply",
"release": "Release",
"release_title": "Release address?",
"release_description": "Address {{ip}} will be removed from the guest interface and released in IPAM.",
"address_added": "Address {{ip}} assigned",
"address_released": "Address {{ip}} released",
"address_released_not_removed": "{{ip}} released in IPAM, but could not be removed from the guest: {{error}}",
"address_runtime_only": "{{ip}} is up but not persisted: guest network stack was not recognised",
"address_reserved_not_applied": "{{ip}} reserved but not applied: {{error}}",
"address_mode": "Address",
"next_free": "Next free",
"manual_ip": "Enter manually",
"target_interface": "Guest interface",
"any_pool": "Any",
"pool": "Pool",
"err_network_required": "Select a network",
"err_bad_ip": "Invalid IP address",
"extra_addresses": "+{{count}} more"
},
"logs": {
"level": "Level",

View File

@@ -1401,6 +1401,7 @@
"no_unlinked": "Все VM связаны",
"link_allocations": "Сканировать и связать",
"linked": "Связано",
"link_skipped": "Пропущено: {{count}}",
"ip_history": "История по IP",
"pool_required_hint": "Авто-выделение требует пул с auto-assign в сети.",
"resource_required": "Укажите ресурс перед выделением IP.",
@@ -1465,7 +1466,40 @@
"allocation_created": "Аллокация создана",
"allocation_updated": "Аллокация обновлена",
"delete_allocation_confirm": "Освободить IP {{ip}}?",
"notes": "Заметки"
"notes": "Заметки",
"addresses": "IP-адреса",
"add_address": "Добавить IP",
"add_address_title": "Добавить IP-адрес",
"add_address_description": "Адрес выделяется в IPAM и навешивается на интерфейс гостя дополнительным (alias).",
"add_address_submit": "Выдать адрес",
"no_addresses": "Адресов в IPAM нет",
"primary": "основной",
"make_primary": "Сделать основным адресом",
"primary_managed_by_nic": "Основной адрес задаётся конфигом интерфейса",
"from_nic_config": "из конфига NIC",
"applied": "применён",
"not_applied": "не применён",
"runtime_only": "до перезагрузки",
"runtime_only_hint": "Адрес поднят, но стек сети гостя не распознан — после перезагрузки исчезнет",
"discovered": "обнаружен",
"apply": "Применить",
"release": "Освободить",
"release_title": "Освободить адрес?",
"release_description": "Адрес {{ip}} будет снят с интерфейса гостя и освобождён в IPAM.",
"address_added": "Адрес {{ip}} назначен",
"address_released": "Адрес {{ip}} освобождён",
"address_released_not_removed": "{{ip}} освобождён в IPAM, но снять его с гостя не удалось: {{error}}",
"address_runtime_only": "{{ip}} поднят, но не закреплён: стек сети гостя не распознан",
"address_reserved_not_applied": "{{ip}} зарезервирован, но не применён: {{error}}",
"address_mode": "Адрес",
"next_free": "Следующий свободный",
"manual_ip": "Указать вручную",
"target_interface": "Интерфейс гостя",
"any_pool": "Любой",
"pool": "Пул",
"err_network_required": "Выберите сеть",
"err_bad_ip": "Некорректный IP-адрес",
"extra_addresses": "ещё {{count}}"
},
"logs": {
"level": "Уровень",

View File

@@ -116,6 +116,14 @@ export interface VMInstance {
netin_rate?: number;
netout_rate?: number;
ip_address?: string;
/** Все адреса гостя; ip_address — основной из этого списка */
ips?: Array<{
ip: string;
is_primary: boolean;
assignment_kind?: 'primary' | 'alias';
target_interface?: string | null;
apply_status?: GuestAddressApplyStatus | null;
}>;
tags?: string;
template?: boolean;
description?: string;
@@ -397,10 +405,41 @@ export interface IPAMAllocation {
fqdn?: string;
allocation_type?: string;
notes?: string;
/** Основной адрес гостя — он попадает в колонку IP списка инстансов */
is_primary?: boolean;
/** primary — задан конфигом NIC, alias — навешен внутри гостя */
assignment_kind?: 'primary' | 'alias';
target_interface?: string | null;
apply_status?: GuestAddressApplyStatus | null;
apply_error?: string | null;
applied_at?: string | null;
created_at: string;
updated_at: string;
}
/** Состояние применения alias-адреса к живому гостю */
export type GuestAddressApplyStatus = 'applied' | 'runtime_only' | 'pending' | 'failed';
/** Один адрес инстанса на вкладке «Сеть» */
export interface GuestAddress {
id: number;
ip_address: string;
prefix?: string | null;
network_id: number;
network_name?: string | null;
gateway?: string | null;
mac_address?: string | null;
status: string;
is_primary: boolean;
assignment_kind: 'primary' | 'alias';
target_interface?: string | null;
apply_status?: GuestAddressApplyStatus | null;
apply_error?: string | null;
applied_at?: string | null;
hostname?: string | null;
notes?: string | null;
}
// ==================== Template Types ====================
export interface OSTemplateGroup {