Compare commits
2 Commits
f9425082f7
...
6796903712
| Author | SHA1 | Date | |
|---|---|---|---|
| 6796903712 | |||
| c9d8e8207f |
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
163
backend/app/services/guest_ip/sync_aliases.sh
Normal file
163
backend/app/services/guest_ip/sync_aliases.sh
Normal 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"
|
||||
222
backend/app/services/guest_ip_service.py
Normal file
222
backend/app/services/guest_ip_service.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
105
backend/tests/test_container_interfaces.py
Normal file
105
backend/tests/test_container_interfaces.py
Normal 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()
|
||||
298
backend/tests/test_guest_ip_aliases_script.py
Normal file
298
backend/tests/test_guest_ip_aliases_script.py
Normal 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
|
||||
218
backend/tests/test_guest_ip_service.py
Normal file
218
backend/tests/test_guest_ip_service.py
Normal 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 == []
|
||||
178
backend/tests/test_ipam_link.py
Normal file
178
backend/tests/test_ipam_link.py
Normal 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
|
||||
257
backend/tests/test_ipam_multi_ip.py
Normal file
257
backend/tests/test_ipam_multi_ip.py
Normal 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")
|
||||
74
backend/tests/test_vm_list_ipam_lookup.py
Normal file
74
backend/tests/test_vm_list_ipam_lookup.py
Normal 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"] == ""
|
||||
235
frontend/src/features/instances/AddGuestIPDialog.tsx
Normal file
235
frontend/src/features/instances/AddGuestIPDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Уровень",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user