Compare commits
9 Commits
v1.17.0
...
6796903712
| Author | SHA1 | Date | |
|---|---|---|---|
| 6796903712 | |||
| c9d8e8207f | |||
| f9425082f7 | |||
| dc53c415db | |||
| 7d60afb9fa | |||
| 30e92da8b5 | |||
|
|
4f290b92fb | ||
|
|
295d3fc06e | ||
|
|
31d17fca61 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -87,3 +87,6 @@ docs/superpowers/
|
|||||||
|
|
||||||
# Планы разработки не коммитим в репозиторий
|
# Планы разработки не коммитим в репозиторий
|
||||||
docs/plans/
|
docs/plans/
|
||||||
|
|
||||||
|
# Сводки сессий (локальные, не для репозитория)
|
||||||
|
docs/summaries/
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to PVEmanager will be documented in this file.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 🖥️ LXC console — failed termproxy tasks and the black screen
|
||||||
|
|
||||||
|
- **⚠️ Breaking change: `GET /api/{server_id}/container/{vmid}/terminal` has been removed.** The WebSocket endpoint `/proxmox/ws/terminal/{server_id}/{node}/{vmid}` creates the termproxy session itself, so the REST call was unused by the panel and left an orphaned session on the node on every invocation. External clients should open the WebSocket directly
|
||||||
|
- **Opening an LXC console no longer leaves a failed task on the node** — `failed waiting for client: timed out` / `TASK ERROR: command '/usr/bin/termproxy 5900 ... vzctlconsole<id>' failed`. The console page created a termproxy session over REST (using only the `node` field of the response and discarding port and ticket), then a second one over the WebSocket endpoint and attached to that one. Nobody ever attached to the first, so Proxmox waited ~10 s for a client and failed the task
|
||||||
|
- **Console no longer shows a black screen with a "connected" badge in dev** — React StrictMode mounts the effect twice and the connect helpers are async, so effect cleanup ran before they had assigned `wsRef`/`termRef` and cleaned up nothing. Two xterm instances ended up in the same container: the one filling the viewport was orphaned while the live one was pushed out of the overflow-hidden area. Sessions now carry a cancellation guard, and both mount and the reconnect button go through a single entry point that tears the previous session down first. The same guard was applied to the node shell page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔍 NFS storage dialog — scan for available exports
|
||||||
|
|
||||||
|
- **Added a "scan exports" button** next to the server field when adding an NFS storage — calls Proxmox's `GET /nodes/{node}/scan/nfs` and turns the export path field from a free-text input into a select populated with the exports actually found on the target server, instead of requiring the path to be typed by hand and guessed
|
||||||
|
- New endpoint `GET /api/backups/scan-nfs/{server_id}?server=<ip>` on the backend, running the scan through the server's first available node
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [v1.17.0] - 2026-08-20
|
## [v1.17.0] - 2026-08-20
|
||||||
|
|
||||||
### 💾 Move LXC container disks to another storage
|
### 💾 Move LXC container disks to another storage
|
||||||
|
|||||||
@@ -4,6 +4,23 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 🖥️ Консоль LXC — ошибки termproxy и чёрный экран
|
||||||
|
|
||||||
|
- **⚠️ Ломающее изменение: эндпоинт `GET /api/{server_id}/container/{vmid}/terminal` удалён.** WebSocket-эндпоинт `/proxmox/ws/terminal/{server_id}/{node}/{vmid}` создаёт termproxy-сессию сам, поэтому REST-вызов не использовался панелью и при каждом вызове оставлял на ноде висячую сессию. Внешним клиентам следует открывать WebSocket напрямую
|
||||||
|
- **Открытие консоли LXC больше не оставляет на ноде задачу с ошибкой** — `failed waiting for client: timed out` / `TASK ERROR: command '/usr/bin/termproxy 5900 ... vzctlconsole<id>' failed`. Страница консоли создавала termproxy-сессию через REST (используя из ответа только поле `node` и выбрасывая порт и тикет), затем вторую через WebSocket-эндпоинт и подключалась ко второй. К первой не подключался никто — Proxmox ждал клиента ~10 секунд и завершал задачу ошибкой
|
||||||
|
- **Консоль больше не показывает чёрный экран при статусе «connected» в dev-режиме** — React StrictMode монтирует эффект дважды, а connect-функции асинхронны, поэтому cleanup эффекта срабатывал до того, как они записывали `wsRef`/`termRef`, и ничего не чистил. В одном контейнере оказывалось два xterm: видимый был осиротевшим, а живой уходил за границу области с `overflow-hidden`. Теперь у сессии есть флаг отмены, а монтирование и кнопка «Переподключиться» идут через единую точку входа, которая сначала гасит предыдущую сессию. Тот же флаг применён к странице shell ноды
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔍 Диалог NFS-хранилища — поиск доступных экспортов
|
||||||
|
|
||||||
|
- **Добавлена кнопка поиска экспортов** рядом с полем «Сервер» при добавлении NFS-хранилища — вызывает `GET /nodes/{node}/scan/nfs` Proxmox и превращает поле пути экспорта из текстового ввода в выпадающий список с реальными экспортами, найденными на целевом сервере, вместо ручного набора пути наугад
|
||||||
|
- Новый эндпоинт `GET /api/backups/scan-nfs/{server_id}?server=<ip>` на бэкенде — сканирование выполняется через первую доступную ноду сервера
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [v1.17.0] - 2026-08-20
|
## [v1.17.0] - 2026-08-20
|
||||||
|
|
||||||
### 💾 Перенос дисков LXC-контейнера на другое хранилище
|
### 💾 Перенос дисков LXC-контейнера на другое хранилище
|
||||||
|
|||||||
2
WIKI.md
2
WIKI.md
@@ -262,6 +262,7 @@ This section summarizes key API endpoints. All require JWT authentication.
|
|||||||
### Backups & Storages
|
### Backups & Storages
|
||||||
|
|
||||||
- `GET /api/backups/storages/{server_id}` — list storages
|
- `GET /api/backups/storages/{server_id}` — list storages
|
||||||
|
- `GET /api/backups/scan-nfs/{server_id}?server=<ip>` — scan an NFS server for available exports
|
||||||
- `POST /api/backups/storages/{server_id}` — add storage
|
- `POST /api/backups/storages/{server_id}` — add storage
|
||||||
- `PUT /api/backups/storages/{server_id}/{storage_id}` — update storage
|
- `PUT /api/backups/storages/{server_id}/{storage_id}` — update storage
|
||||||
- `DELETE /api/backups/storages/{server_id}/{storage_id}` — delete storage
|
- `DELETE /api/backups/storages/{server_id}/{storage_id}` — delete storage
|
||||||
@@ -2173,6 +2174,7 @@ curl -H "Authorization: Bearer eyJ..." \
|
|||||||
| GET | `/logs/api/logs` | Get audit logs |
|
| GET | `/logs/api/logs` | Get audit logs |
|
||||||
| GET | `/logs/api/stats` | Get log statistics |
|
| GET | `/logs/api/stats` | Get log statistics |
|
||||||
| GET | `/api/backups/storages/{server_id}` | List Proxmox storages |
|
| GET | `/api/backups/storages/{server_id}` | List Proxmox storages |
|
||||||
|
| GET | `/api/backups/scan-nfs/{server_id}` | Scan NFS server for exports |
|
||||||
| POST | `/api/backups/create` | Create VM/LXC backup |
|
| POST | `/api/backups/create` | Create VM/LXC backup |
|
||||||
| POST | `/api/backups/restore` | Restore VM/LXC from backup |
|
| POST | `/api/backups/restore` | Restore VM/LXC from backup |
|
||||||
| GET | `/api/backups/list/{server_id}` | List backup files |
|
| GET | `/api/backups/list/{server_id}` | List backup files |
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ CORS_ORIGINS=*
|
|||||||
### Резервные копии и хранилища
|
### Резервные копии и хранилища
|
||||||
|
|
||||||
- `GET /api/backups/storages/{server_id}` — список хранилищ
|
- `GET /api/backups/storages/{server_id}` — список хранилищ
|
||||||
|
- `GET /api/backups/scan-nfs/{server_id}?server=<ip>` — сканировать NFS-сервер на доступные экспорты
|
||||||
- `POST /api/backups/storages/{server_id}` — добавить хранилище
|
- `POST /api/backups/storages/{server_id}` — добавить хранилище
|
||||||
- `PUT /api/backups/storages/{server_id}/{storage_id}` — обновить хранилище
|
- `PUT /api/backups/storages/{server_id}/{storage_id}` — обновить хранилище
|
||||||
- `DELETE /api/backups/storages/{server_id}/{storage_id}` — удалить хранилище
|
- `DELETE /api/backups/storages/{server_id}/{storage_id}` — удалить хранилище
|
||||||
@@ -2086,6 +2087,7 @@ curl -H "Authorization: Bearer eyJ..." \
|
|||||||
| GET | `/logs/api/logs` | Получить логи аудита |
|
| GET | `/logs/api/logs` | Получить логи аудита |
|
||||||
| GET | `/logs/api/stats` | Статистика логов |
|
| GET | `/logs/api/stats` | Статистика логов |
|
||||||
| GET | `/api/backups/storages/{server_id}` | Список хранилищ Proxmox |
|
| GET | `/api/backups/storages/{server_id}` | Список хранилищ Proxmox |
|
||||||
|
| GET | `/api/backups/scan-nfs/{server_id}` | Сканировать NFS-сервер на экспорты |
|
||||||
| POST | `/api/backups/create` | Создать копию VM/LXC |
|
| POST | `/api/backups/create` | Создать копию VM/LXC |
|
||||||
| POST | `/api/backups/restore` | Восстановить VM/LXC из копии |
|
| POST | `/api/backups/restore` | Восстановить VM/LXC из копии |
|
||||||
| GET | `/api/backups/list/{server_id}` | Список файлов копий |
|
| GET | `/api/backups/list/{server_id}` | Список файлов копий |
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ IP Address Management endpoints for networks, pools, and allocations.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import ipaddress as ipaddress_module
|
import ipaddress as ipaddress_module
|
||||||
from typing import List, Optional
|
from typing import List, NamedTuple, Optional
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
@@ -21,6 +21,8 @@ from ..schemas import (
|
|||||||
IPAMHistoryResponse, IPAMAutoAllocateRequest
|
IPAMHistoryResponse, IPAMAutoAllocateRequest
|
||||||
)
|
)
|
||||||
from ..ipam_service import IPAMService
|
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
|
from ..auth import get_current_user, PermissionChecker
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -382,6 +384,8 @@ def get_allocations(
|
|||||||
pool_id: Optional[int] = None,
|
pool_id: Optional[int] = None,
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
resource_type: Optional[str] = None,
|
resource_type: Optional[str] = None,
|
||||||
|
proxmox_server_id: Optional[int] = None,
|
||||||
|
proxmox_vmid: Optional[int] = None,
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
limit: int = Query(default=100, le=1000),
|
limit: int = Query(default=100, le=1000),
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -402,6 +406,13 @@ def get_allocations(
|
|||||||
|
|
||||||
if resource_type:
|
if resource_type:
|
||||||
query = query.filter(IPAMAllocation.resource_type == 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:
|
if search:
|
||||||
search_filter = f"%{search}%"
|
search_filter = f"%{search}%"
|
||||||
@@ -789,13 +800,29 @@ async def cleanup_orphan_allocations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _extract_primary_ipv4(interfaces: list, parsed_networks: list):
|
class DiscoveredAddress(NamedTuple):
|
||||||
"""Extract first IPv4 that belongs to any IPAM network from interface list."""
|
"""Адрес гостя, найденный в одной из известных 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 iface in interfaces:
|
||||||
for ip_info in iface.get('ips', []):
|
for ip_info in iface.get('ips', []):
|
||||||
if ip_info.get('type') != 'ipv4':
|
if ip_info.get('type') != 'ipv4':
|
||||||
continue
|
continue
|
||||||
addr = ip_info.get('address', '')
|
addr = ip_info.get('address', '')
|
||||||
|
if not addr or addr in seen:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
ip_obj = ipaddress_module.ip_address(addr)
|
ip_obj = ipaddress_module.ip_address(addr)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -804,8 +831,15 @@ def _extract_primary_ipv4(interfaces: list, parsed_networks: list):
|
|||||||
continue
|
continue
|
||||||
for net, net_obj in parsed_networks:
|
for net, net_obj in parsed_networks:
|
||||||
if ip_obj in net_obj:
|
if ip_obj in net_obj:
|
||||||
return addr, net
|
seen.add(addr)
|
||||||
return None, None
|
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")
|
@router.get("/api/unlinked")
|
||||||
@@ -814,21 +848,26 @@ def get_unlinked_allocations(
|
|||||||
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Return VM instances that have no IPAM allocation linked to them (by vmid+server_id).
|
Return VM instances whose current address is not registered in IPAM.
|
||||||
Only VMs whose cached ip_address falls within an active IPAM network are included.
|
|
||||||
|
У гостя может быть несколько адресов, поэтому «связан» определяется не по
|
||||||
|
наличию хоть какой-то аллокации, а по конкретному адресу: если в кэше
|
||||||
|
инстанса стоит IP, которого нет среди его аллокаций, гость показывается
|
||||||
|
как несвязанный. Учитываются только адреса из активных IPAM-сетей.
|
||||||
"""
|
"""
|
||||||
unlinked = []
|
unlinked = []
|
||||||
|
|
||||||
# All live VMs
|
# All live VMs
|
||||||
vms = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None)).all()
|
vms = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None)).all()
|
||||||
|
|
||||||
# Pre-load all linked (proxmox_server_id, proxmox_vmid) pairs for fast lookup
|
# (server_id, vmid) -> множество уже учтённых адресов гостя
|
||||||
linked_pairs = set(
|
linked_ips = {}
|
||||||
(a.proxmox_server_id, a.proxmox_vmid)
|
for alloc in db.query(
|
||||||
for a in db.query(IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid).filter(
|
IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid, IPAMAllocation.ip_address
|
||||||
IPAMAllocation.proxmox_vmid.isnot(None)
|
).filter(IPAMAllocation.proxmox_vmid.isnot(None)).all():
|
||||||
).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()}
|
servers_map = {s.id: s for s in db.query(ProxmoxServer).all()}
|
||||||
|
|
||||||
@@ -841,12 +880,11 @@ def get_unlinked_allocations(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
for vm in vms:
|
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
|
# Skip VMs with no IP or an IP outside all IPAM networks
|
||||||
if not vm.ip_address:
|
if not vm.ip_address:
|
||||||
continue
|
continue
|
||||||
|
if vm.ip_address in linked_ips.get((vm.server_id, vm.vmid), ()):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
vm_ip = ipaddress_module.ip_address(vm.ip_address)
|
vm_ip = ipaddress_module.ip_address(vm.ip_address)
|
||||||
if not any(vm_ip in net for net in ipam_nets):
|
if not any(vm_ip in net for net in ipam_nets):
|
||||||
@@ -972,9 +1010,9 @@ def link_allocations_to_vms(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
for vm in server_vms:
|
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
|
# For this VM, further filter networks by proxmox_node if set
|
||||||
# (e.g. network tagged to pve1 should not match VMs on pve2)
|
# (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}")
|
logger.debug(f"[IPAM link] Cannot get interfaces for {vm.name}/{vm.vmid}: {e}")
|
||||||
interfaces = []
|
interfaces = []
|
||||||
|
|
||||||
# Find first IPv4 in one of this VM's applicable IPAM networks
|
# Все IPv4 гостя, попадающие в его IPAM-сети
|
||||||
ip_found, matching_network = _extract_primary_ipv4(interfaces, vm_networks)
|
discovered = _extract_ipv4s(interfaces, vm_networks)
|
||||||
|
|
||||||
if not ip_found:
|
if not discovered:
|
||||||
not_found.append({'ip_address': '-', 'resource_name': vm.name,
|
not_found.append({'ip_address': '-', 'resource_name': vm.name,
|
||||||
'reason': 'no_ip_in_ipam_network'})
|
'reason': 'no_ip_in_ipam_network'})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
alloc, error = service.sync_from_proxmox_vm(
|
already_linked = {
|
||||||
network_id=matching_network.id,
|
a.ip_address for a in
|
||||||
proxmox_server_id=server_id,
|
service.find_allocations_by_resource(server_id, vm.vmid)
|
||||||
vmid=vm.vmid,
|
}
|
||||||
vm_name=vm.name,
|
|
||||||
vm_type=vm.vm_type,
|
|
||||||
ip_address=ip_found,
|
|
||||||
node=vm.node,
|
|
||||||
synced_by=synced_by
|
|
||||||
)
|
|
||||||
|
|
||||||
if alloc:
|
for found in discovered:
|
||||||
# Update the cached ip_address in vm_instances as well
|
if found.ip in already_linked:
|
||||||
vm.ip_address = ip_found
|
continue # этот адрес гостя уже учтён
|
||||||
linked_pairs.add((server_id, vm.vmid)) # avoid double-linking
|
|
||||||
linked.append({
|
alloc, error = service.sync_from_proxmox_vm(
|
||||||
'ip_address': ip_found,
|
network_id=found.network.id,
|
||||||
'resource_name': vm.name,
|
proxmox_server_id=server_id,
|
||||||
'vmid': vm.vmid,
|
vmid=vm.vmid,
|
||||||
'server_id': server_id
|
vm_name=vm.name,
|
||||||
})
|
vm_type=vm.vm_type,
|
||||||
logger.info(f"[IPAM link] Linked {ip_found} -> VM {vm.vmid} ({vm.name}) on {server.name}")
|
ip_address=found.ip,
|
||||||
else:
|
node=vm.node,
|
||||||
not_found.append({'ip_address': ip_found, 'resource_name': vm.name,
|
mac_address=found.mac,
|
||||||
'reason': error or 'sync_error'})
|
synced_by=synced_by,
|
||||||
logger.warning(f"[IPAM link] Failed {ip_found} for {vm.name}: {error}")
|
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:
|
if linked:
|
||||||
try:
|
try:
|
||||||
@@ -1047,3 +1104,261 @@ def link_allocations_to_vms(
|
|||||||
'not_found_count': len(not_found)
|
'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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,35 @@ def get_storages(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/backups/scan-nfs/{server_id}")
|
||||||
|
def scan_nfs(
|
||||||
|
server_id: int,
|
||||||
|
server: str,
|
||||||
|
node: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("backup:manage")),
|
||||||
|
):
|
||||||
|
"""Scan an NFS server for available exports, to populate the storage-add dialog."""
|
||||||
|
pve_server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||||
|
if not pve_server:
|
||||||
|
raise HTTPException(status_code=404, detail="Server not found")
|
||||||
|
try:
|
||||||
|
client = _get_proxmox_client(pve_server)
|
||||||
|
target_node = node
|
||||||
|
if not target_node:
|
||||||
|
nodes = client.get_nodes()
|
||||||
|
if not nodes:
|
||||||
|
raise HTTPException(status_code=400, detail="No nodes available to scan from")
|
||||||
|
target_node = nodes[0]["node"]
|
||||||
|
exports = client.scan_nfs_exports(target_node, server)
|
||||||
|
return JSONResponse(content={"exports": exports})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error scanning NFS exports on {server} for server {server_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/backups/storages/{server_id}")
|
@router.post("/api/backups/storages/{server_id}")
|
||||||
async def create_storage(
|
async def create_storage(
|
||||||
server_id: int,
|
server_id: int,
|
||||||
|
|||||||
@@ -164,20 +164,26 @@ def get_all_virtual_machines(
|
|||||||
ipam_networks = db.query(IPAMNetwork).all()
|
ipam_networks = db.query(IPAMNetwork).all()
|
||||||
network_map = {n.id: n for n in ipam_networks}
|
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_vmid = {}
|
||||||
ipam_by_name = {}
|
ipam_all_by_vmid = {}
|
||||||
|
|
||||||
for alloc in ipam_allocations:
|
for alloc in ipam_allocations:
|
||||||
if alloc.proxmox_server_id and alloc.proxmox_vmid:
|
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:
|
elif alloc.resource_id:
|
||||||
ipam_by_vmid[(None, alloc.resource_id)] = alloc
|
ipam_by_vmid.setdefault((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
|
|
||||||
|
|
||||||
# Build base query for cached VMs (not deleted, not templates)
|
# Build base query for cached VMs (not deleted, not templates)
|
||||||
# joinedload(owner) avoids an N+1 query when rendering the owner column.
|
# joinedload(owner) avoids an N+1 query when rendering the owner column.
|
||||||
query = db.query(VMInstance).options(joinedload(VMInstance.owner)).filter(
|
query = db.query(VMInstance).options(joinedload(VMInstance.owner)).filter(
|
||||||
@@ -267,14 +273,29 @@ def get_all_virtual_machines(
|
|||||||
|
|
||||||
# Get IP from IPAM or cache
|
# Get IP from IPAM or cache
|
||||||
ipam_alloc = (
|
ipam_alloc = (
|
||||||
ipam_by_vmid.get((vm.server_id, vm.vmid)) or
|
ipam_by_vmid.get((vm.server_id, vm.vmid)) or
|
||||||
ipam_by_vmid.get((None, vm.vmid)) or
|
ipam_by_vmid.get((None, vm.vmid))
|
||||||
ipam_by_name.get(vm.name.lower())
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ip_address = ipam_alloc.ip_address if ipam_alloc else (vm.ip_address or "")
|
ip_address = ipam_alloc.ip_address if ipam_alloc else (vm.ip_address or "")
|
||||||
ip_hostname = ipam_alloc.hostname if ipam_alloc else ""
|
ip_hostname = ipam_alloc.hostname if ipam_alloc else ""
|
||||||
owner = ipam_alloc.allocated_by 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
|
# Get network name from IPAM
|
||||||
ip_network_name = ""
|
ip_network_name = ""
|
||||||
@@ -312,8 +333,11 @@ def get_all_virtual_machines(
|
|||||||
"cores": vm.cores or 0,
|
"cores": vm.cores or 0,
|
||||||
"memory": vm.memory or 0,
|
"memory": vm.memory or 0,
|
||||||
"disk": live.get('disk') or 0,
|
"disk": live.get('disk') or 0,
|
||||||
|
# ip/ip_address — основной адрес (обратная совместимость),
|
||||||
|
# ips — все адреса гостя
|
||||||
"ip": ip_address,
|
"ip": ip_address,
|
||||||
"ip_address": ip_address,
|
"ip_address": ip_address,
|
||||||
|
"ips": ip_list,
|
||||||
"ip_hostname": ip_network_name,
|
"ip_hostname": ip_network_name,
|
||||||
"os": os_template,
|
"os": os_template,
|
||||||
"os_template": os_template,
|
"os_template": os_template,
|
||||||
@@ -2884,79 +2908,6 @@ def get_vm_terminal(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/{server_id}/container/{vmid}/terminal")
|
|
||||||
def get_container_terminal(
|
|
||||||
server_id: int,
|
|
||||||
vmid: int,
|
|
||||||
node: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(PermissionChecker("vm:console"))
|
|
||||||
):
|
|
||||||
"""Получить данные для xterm.js терминального подключения к LXC контейнеру"""
|
|
||||||
import requests
|
|
||||||
|
|
||||||
# VPS-style user isolation: check container ownership for limited users
|
|
||||||
require_vm_access(db, current_user, server_id, vmid)
|
|
||||||
|
|
||||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
|
||||||
if not server:
|
|
||||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
|
||||||
|
|
||||||
try:
|
|
||||||
password_to_use = server.password if server.password else None
|
|
||||||
auth_username = server.api_user.split("!")[0] if "!" in server.api_user else server.api_user
|
|
||||||
|
|
||||||
if not password_to_use:
|
|
||||||
raise HTTPException(status_code=400, detail="Terminal requires password authentication")
|
|
||||||
|
|
||||||
# 1. Получаем auth ticket
|
|
||||||
auth_response = requests.post(
|
|
||||||
f"https://{server.ip_address}:8006/api2/json/access/ticket",
|
|
||||||
data={"username": auth_username, "password": password_to_use},
|
|
||||||
verify=server.verify_ssl,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if auth_response.status_code != 200:
|
|
||||||
raise HTTPException(status_code=401, detail="Failed to authenticate to Proxmox")
|
|
||||||
|
|
||||||
auth_data = auth_response.json().get("data", {})
|
|
||||||
auth_ticket = auth_data.get("ticket")
|
|
||||||
csrf_token = auth_data.get("CSRFPreventionToken")
|
|
||||||
|
|
||||||
# 2. Создаём terminal proxy
|
|
||||||
term_response = requests.post(
|
|
||||||
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/lxc/{vmid}/termproxy",
|
|
||||||
headers={"CSRFPreventionToken": csrf_token},
|
|
||||||
cookies={"PVEAuthCookie": auth_ticket},
|
|
||||||
verify=server.verify_ssl,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if term_response.status_code != 200:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to create terminal proxy: {term_response.text}")
|
|
||||||
|
|
||||||
term_data = term_response.json().get("data", {})
|
|
||||||
|
|
||||||
response_data = {
|
|
||||||
'port': term_data.get('port'),
|
|
||||||
'ticket': term_data.get('ticket'),
|
|
||||||
'host': server.ip_address,
|
|
||||||
'node': node,
|
|
||||||
'vmid': vmid,
|
|
||||||
'type': 'lxc',
|
|
||||||
'auth_ticket': auth_ticket
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"User {current_user.username} opened terminal for LXC {vmid}")
|
|
||||||
return JSONResponse(content=response_data)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting terminal for LXC {vmid}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/{server_id}/container/{vmid}/exec")
|
@router.post("/api/{server_id}/container/{vmid}/exec")
|
||||||
async def exec_in_container(
|
async def exec_in_container(
|
||||||
server_id: int,
|
server_id: int,
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, or_, text
|
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:
|
def utcnow() -> datetime:
|
||||||
@@ -18,6 +20,21 @@ def utcnow() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +159,8 @@ class IPAMService:
|
|||||||
self,
|
self,
|
||||||
network_id: int,
|
network_id: int,
|
||||||
pool_id: Optional[int] = None,
|
pool_id: Optional[int] = None,
|
||||||
prefer_sequential: bool = True
|
prefer_sequential: bool = True,
|
||||||
|
allow_network_scan: bool = False
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Get the next available IP address from a network or specific pool.
|
Get the next available IP address from a network or specific pool.
|
||||||
@@ -151,6 +169,9 @@ class IPAMService:
|
|||||||
network_id: Network ID
|
network_id: Network ID
|
||||||
pool_id: Optional specific pool ID
|
pool_id: Optional specific pool ID
|
||||||
prefer_sequential: If True, prefer sequential IPs; if False, can use any gap
|
prefer_sequential: If True, prefer sequential IPs; if False, can use any gap
|
||||||
|
allow_network_scan: искать по всей подсети, когда auto-assign пулов
|
||||||
|
нет. Нужно там, где адрес выдаётся точечно (доп. IP инстанса),
|
||||||
|
а не по политике пула.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Available IP address string or None if no IPs available
|
Available IP address string or None if no IPs available
|
||||||
@@ -179,7 +200,7 @@ class IPAMService:
|
|||||||
IPAMPool.auto_assign == True
|
IPAMPool.auto_assign == True
|
||||||
).order_by(IPAMPool.id).all()
|
).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}")
|
logger.warning(f"No active auto-assign pools found for network {network_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -213,7 +234,34 @@ class IPAMService:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.error(f"Invalid IP range in pool {pool.id}: {e}")
|
logger.error(f"Invalid IP range in pool {pool.id}: {e}")
|
||||||
continue
|
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}")
|
logger.warning(f"No available IPs found in network {network_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -234,7 +282,11 @@ class IPAMService:
|
|||||||
allocation_type: str = "static",
|
allocation_type: str = "static",
|
||||||
status: str = "allocated",
|
status: str = "allocated",
|
||||||
allocated_by: Optional[str] = None,
|
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]]:
|
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Allocate an IP address.
|
Allocate an IP address.
|
||||||
@@ -310,7 +362,11 @@ class IPAMService:
|
|||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
fqdn=fqdn,
|
fqdn=fqdn,
|
||||||
allocated_by=allocated_by,
|
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)
|
self.db.add(allocation)
|
||||||
@@ -436,46 +492,51 @@ class IPAMService:
|
|||||||
reason: Optional[str] = None
|
reason: Optional[str] = None
|
||||||
) -> Tuple[bool, Optional[str]]:
|
) -> Tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Release IP allocation by Proxmox VM/container ID.
|
Release every IP allocation of a Proxmox VM/container.
|
||||||
Used when VM/container is deleted.
|
Used when the 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
|
|
||||||
|
|
||||||
Returns:
|
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
|
allocations = self.find_allocations_by_resource(proxmox_server_id, proxmox_vmid)
|
||||||
allocation = self.find_allocation_by_resource(proxmox_server_id, proxmox_vmid)
|
|
||||||
|
|
||||||
# Fallback: search by resource_id if proxmox fields are not set.
|
# Fallback: search by resource_id if proxmox fields are not set.
|
||||||
# ВАЖНО: только среди записей БЕЗ proxmox_server_id — иначе удаление
|
# ВАЖНО: только среди записей БЕЗ proxmox_server_id — иначе удаление
|
||||||
# VM с vmid=100 на сервере A освобождало IP виртуалки с тем же vmid
|
# VM с vmid=100 на сервере A освобождало IP виртуалки с тем же vmid
|
||||||
# на сервере B (записи с заполненным server_id находит основной поиск).
|
# на сервере B (записи с заполненным server_id находит основной поиск).
|
||||||
if not allocation:
|
if not allocations:
|
||||||
allocation = self.db.query(IPAMAllocation).filter(
|
allocations = self.db.query(IPAMAllocation).filter(
|
||||||
IPAMAllocation.resource_id == proxmox_vmid,
|
IPAMAllocation.resource_id == proxmox_vmid,
|
||||||
IPAMAllocation.proxmox_server_id.is_(None),
|
IPAMAllocation.proxmox_server_id.is_(None),
|
||||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||||
).first()
|
).all()
|
||||||
if allocation:
|
if allocations:
|
||||||
logger.debug(f"Found allocation by resource_id={proxmox_vmid} (legacy)")
|
logger.debug(f"Found allocation(s) by resource_id={proxmox_vmid} (legacy)")
|
||||||
|
|
||||||
if not allocation:
|
if not allocations:
|
||||||
logger.debug(f"No IPAM allocation found for server {proxmox_server_id}, vmid {proxmox_vmid}")
|
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
|
return False, None # Not an error, just no allocation to release
|
||||||
|
|
||||||
ip_address = allocation.ip_address
|
released, errors = [], []
|
||||||
success, error = self.release_ip(ip_address, released_by, reason)
|
for allocation in allocations:
|
||||||
|
ip_address = allocation.ip_address
|
||||||
if success:
|
success, error = self.release_ip(ip_address, released_by, reason)
|
||||||
logger.info(f"Auto-released IP {ip_address} for deleted VM/container {proxmox_vmid}")
|
if success:
|
||||||
return True, ip_address
|
released.append(ip_address)
|
||||||
|
else:
|
||||||
return False, error
|
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(
|
def update_allocation(
|
||||||
self,
|
self,
|
||||||
@@ -537,17 +598,65 @@ class IPAMService:
|
|||||||
|
|
||||||
return allocation, None
|
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(
|
def find_allocation_by_resource(
|
||||||
self,
|
self,
|
||||||
proxmox_server_id: int,
|
proxmox_server_id: int,
|
||||||
proxmox_vmid: int
|
proxmox_vmid: int
|
||||||
) -> Optional[IPAMAllocation]:
|
) -> 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_server_id == proxmox_server_id,
|
||||||
IPAMAllocation.proxmox_vmid == proxmox_vmid,
|
IPAMAllocation.proxmox_vmid == proxmox_vmid
|
||||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
).all()
|
||||||
).first()
|
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:
|
def release_all_by_server(self, proxmox_server_id: int, released_by: str = None) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -675,7 +784,17 @@ class IPAMService:
|
|||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
# ==================== Sync & Scan Operations ====================
|
# ==================== 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(
|
def sync_from_proxmox_vm(
|
||||||
self,
|
self,
|
||||||
network_id: int,
|
network_id: int,
|
||||||
@@ -686,18 +805,36 @@ class IPAMService:
|
|||||||
ip_address: str,
|
ip_address: str,
|
||||||
node: str,
|
node: str,
|
||||||
mac_address: Optional[str] = None,
|
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]]:
|
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Sync/create allocation for a Proxmox VM.
|
Sync/create allocation for a Proxmox VM.
|
||||||
Called when discovering VMs from Proxmox.
|
Called when discovering VMs from Proxmox.
|
||||||
|
|
||||||
|
Гость может иметь несколько адресов: первый заведённый становится
|
||||||
|
основным, последующие — обычными записями того же гостя.
|
||||||
"""
|
"""
|
||||||
# Check if already allocated
|
# Check if already allocated
|
||||||
existing = self.db.query(IPAMAllocation).filter(
|
existing = self.db.query(IPAMAllocation).filter(
|
||||||
IPAMAllocation.ip_address == ip_address
|
IPAMAllocation.ip_address == ip_address
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing:
|
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
|
# Update existing allocation
|
||||||
if existing.proxmox_vmid != vmid or existing.proxmox_server_id != proxmox_server_id:
|
if existing.proxmox_vmid != vmid or existing.proxmox_server_id != proxmox_server_id:
|
||||||
return self.update_allocation(
|
return self.update_allocation(
|
||||||
@@ -725,7 +862,14 @@ class IPAMService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return existing, None
|
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(
|
return self.allocate_ip(
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
network_id=network_id,
|
network_id=network_id,
|
||||||
@@ -736,7 +880,10 @@ class IPAMService:
|
|||||||
proxmox_node=node,
|
proxmox_node=node,
|
||||||
mac_address=mac_address,
|
mac_address=mac_address,
|
||||||
allocated_by=synced_by,
|
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]]:
|
def detect_conflicts(self, network_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||||
@@ -801,8 +948,8 @@ class IPAMService:
|
|||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
network_id=network_id,
|
network_id=network_id,
|
||||||
action=action,
|
action=action,
|
||||||
old_value=old_value,
|
old_value=_json_safe(old_value),
|
||||||
new_value=new_value,
|
new_value=_json_safe(new_value),
|
||||||
resource_type=resource_type,
|
resource_type=resource_type,
|
||||||
resource_id=resource_id,
|
resource_id=resource_id,
|
||||||
resource_name=resource_name,
|
resource_name=resource_name,
|
||||||
|
|||||||
@@ -87,6 +87,21 @@ class IPAMAllocation(Base):
|
|||||||
|
|
||||||
status = Column(String(20), default="allocated", nullable=False)
|
status = Column(String(20), default="allocated", nullable=False)
|
||||||
allocation_type = Column(String(20), default="static", 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)
|
hostname = Column(String(255), nullable=True, index=True)
|
||||||
fqdn = Column(String(255), nullable=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_network', 'network_id'),
|
||||||
Index('idx_ipam_alloc_resource', 'resource_type', 'resource_id'),
|
Index('idx_ipam_alloc_resource', 'resource_type', 'resource_id'),
|
||||||
Index('idx_ipam_alloc_proxmox', 'proxmox_server_id', 'proxmox_vmid'),
|
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'),
|
Index('idx_ipam_alloc_status', 'status'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import time
|
import time
|
||||||
|
import ipaddress
|
||||||
import urllib3
|
import urllib3
|
||||||
import asyncio
|
import asyncio
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
@@ -10,6 +11,7 @@ from functools import lru_cache
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
from .net_parser import parse_guest_nics
|
||||||
from .mixins.vm import VmMixin
|
from .mixins.vm import VmMixin
|
||||||
from .mixins.lxc import LxcMixin
|
from .mixins.lxc import LxcMixin
|
||||||
from .mixins.cluster import ClusterMixin
|
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}")
|
logger.debug(f"netstat unavailable for node {node}: {e}")
|
||||||
return []
|
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:
|
Args:
|
||||||
node: Имя ноды
|
node: Имя ноды
|
||||||
vmid: ID контейнера
|
vmid: ID контейнера
|
||||||
|
include_live: опрашивать ли /interfaces (бесполезно для остановленного CT)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Список интерфейсов с IP адресами
|
Список интерфейсов: {'name', 'hardware_address', 'ips': [{'address', 'type', 'prefix'}]}
|
||||||
"""
|
"""
|
||||||
if not self.proxmox:
|
if not self.proxmox:
|
||||||
return []
|
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:
|
try:
|
||||||
# Для LXC можем получить IP из конфигурации
|
|
||||||
config = self.proxmox.nodes(node).lxc(vmid).config.get()
|
config = self.proxmox.nodes(node).lxc(vmid).config.get()
|
||||||
interfaces = []
|
for nic in parse_guest_nics(config):
|
||||||
|
iface = _slot(nic.name or f'eth{nic.index}', nic.mac or '')
|
||||||
# Проверяем net0, net1 и т.д.
|
_add_ip(iface, nic.ip or '')
|
||||||
for i in range(10):
|
_add_ip(iface, nic.ip6 or '')
|
||||||
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')]
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Не удалось получить сетевые интерфейсы контейнера {vmid}: {e}")
|
logger.debug(f"Не удалось прочитать конфиг контейнера {vmid}: {e}")
|
||||||
return []
|
|
||||||
|
# 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:
|
def execute_command(self, node: str, vmid: int, command: str, timeout: int = 30) -> Dict:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -239,6 +239,16 @@ class StorageMixin:
|
|||||||
logger.error(f"Ошибка получения статусов хранилищ с {self.host}: {e}")
|
logger.error(f"Ошибка получения статусов хранилищ с {self.host}: {e}")
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
def scan_nfs_exports(self, node: str, server: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Получить список NFS-экспортов удалённого сервера через
|
||||||
|
GET /nodes/{node}/scan/nfs — для выбора export в диалоге добавления
|
||||||
|
хранилища вместо ручного ввода пути.
|
||||||
|
"""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
return self.proxmox.nodes(node).scan.nfs.get(server=server)
|
||||||
|
|
||||||
def get_node_storages(self, node: str, content: str = None) -> List[Dict]:
|
def get_node_storages(self, node: str, content: str = None) -> List[Dict]:
|
||||||
"""Get storages available on a specific node, optionally filtered by content type"""
|
"""Get storages available on a specific node, optionally filtered by content type"""
|
||||||
if not self.proxmox:
|
if not self.proxmox:
|
||||||
|
|||||||
@@ -137,12 +137,10 @@ NEW_DEFAULT_ROLES = [
|
|||||||
"template:view": True,
|
"template:view": True,
|
||||||
"storage:view": True,
|
"storage:view": True,
|
||||||
"firewall:view": True,
|
"firewall:view": True,
|
||||||
"network:view": True,
|
|
||||||
"node:view": True,
|
"node:view": True,
|
||||||
"pool:view": True,
|
"pool:view": True,
|
||||||
"backup:view": True,
|
"backup:view": True,
|
||||||
"backup:create": True,
|
"backup:create": True,
|
||||||
"ipam:view": True,
|
|
||||||
"user:view": True,
|
"user:view": True,
|
||||||
"log:view": True,
|
"log:view": True,
|
||||||
"log:export": True,
|
"log:export": True,
|
||||||
@@ -172,10 +170,8 @@ NEW_DEFAULT_ROLES = [
|
|||||||
"template:view": True,
|
"template:view": True,
|
||||||
"storage:view": True,
|
"storage:view": True,
|
||||||
"firewall:view": True,
|
"firewall:view": True,
|
||||||
"network:view": True,
|
|
||||||
"node:view": True,
|
"node:view": True,
|
||||||
"pool:view": True,
|
"pool:view": True,
|
||||||
"ipam:view": True,
|
|
||||||
# No setting:view — that grants the panel-wide settings page. A
|
# No setting:view — that grants the panel-wide settings page. A
|
||||||
# user's own profile lives at /profile and needs no permission.
|
# user's own profile lives at /profile and needs no permission.
|
||||||
"notification:view": True,
|
"notification:view": True,
|
||||||
@@ -195,10 +191,8 @@ NEW_DEFAULT_ROLES = [
|
|||||||
"template:view": True,
|
"template:view": True,
|
||||||
"storage:view": True,
|
"storage:view": True,
|
||||||
"firewall:view": True,
|
"firewall:view": True,
|
||||||
"network:view": True,
|
|
||||||
"node:view": True,
|
"node:view": True,
|
||||||
"pool:view": True,
|
"pool:view": True,
|
||||||
"ipam:view": True,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -489,7 +483,10 @@ def ensure_default_roles_new_format(conn) -> None:
|
|||||||
# custom roles do not silently lose access after the split.
|
# custom roles do not silently lose access after the split.
|
||||||
GRANULAR_BACKFILL = [
|
GRANULAR_BACKFILL = [
|
||||||
# (umbrella permission, [granular permissions implied by it])
|
# (umbrella permission, [granular permissions implied by it])
|
||||||
("server:view", ["firewall:view", "network:view", "node:view", "storage:view"]),
|
# network:view/manage is deliberately NOT implied by server:view - the
|
||||||
|
# network topology and node/SDN interface config are admin-only, not part
|
||||||
|
# of the baseline "can see servers" umbrella.
|
||||||
|
("server:view", ["firewall:view", "node:view", "storage:view"]),
|
||||||
("vm:view", ["pool:view", "firewall:view"]),
|
("vm:view", ["pool:view", "firewall:view"]),
|
||||||
("server:manage", [
|
("server:manage", [
|
||||||
"firewall:view", "firewall:manage",
|
"firewall:view", "firewall:manage",
|
||||||
|
|||||||
@@ -643,6 +643,13 @@ class IPAMAllocationResponse(IPAMAllocationBase):
|
|||||||
last_seen: Optional[datetime] = None
|
last_seen: Optional[datetime] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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
|
# Extended info
|
||||||
network_name: Optional[str] = None
|
network_name: Optional[str] = None
|
||||||
pool_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 asyncio
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -108,12 +109,17 @@ class MonitoringWorker:
|
|||||||
# задачу failed. Транзитный сетевой сбой не должен «убивать» живую задачу.
|
# задачу failed. Транзитный сетевой сбой не должен «убивать» живую задачу.
|
||||||
UPID_FAIL_THRESHOLD = 3
|
UPID_FAIL_THRESHOLD = 3
|
||||||
|
|
||||||
|
# Как часто перепроверять IP гостя у Proxmox. Синк идёт каждые 10 секунд,
|
||||||
|
# а адрес меняется редко — без троттлинга это сотни лишних запросов в минуту.
|
||||||
|
IP_DETECT_TTL = 300
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.last_vm_states: Dict[str, str] = {} # vm_id -> status
|
self.last_vm_states: Dict[str, str] = {} # vm_id -> status
|
||||||
self.last_resource_alerts: Dict[str, float] = {} # resource_id -> last_alert_time
|
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_states: Dict[int, bool] = {} # server_id -> is_online
|
||||||
self.last_server_alerts: Dict[int, float] = {} # server_id -> last_alert_time
|
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.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:
|
def _create_proxmox_client(self, server: ProxmoxServer) -> ProxmoxClient:
|
||||||
"""Create ProxmoxClient from server model, raising if auth is missing."""
|
"""Create ProxmoxClient from server model, raising if auth is missing."""
|
||||||
@@ -619,18 +625,20 @@ class MonitoringWorker:
|
|||||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# Build IPAM lookups
|
# Build IPAM lookups.
|
||||||
|
# Только по идентификатору гостя: сопоставление по имени давало чужой
|
||||||
|
# адрес одноимённым гостям на разных серверах (два разных "wireguard").
|
||||||
|
# У гостя может быть несколько адресов — в кэш инстанса пишем
|
||||||
|
# основной (is_primary); без флага берём самую раннюю запись.
|
||||||
ipam_by_server_vmid = {} # (server_id, vmid) -> allocation
|
ipam_by_server_vmid = {} # (server_id, vmid) -> allocation
|
||||||
ipam_by_name = {} # hostname/resource_name -> allocation
|
|
||||||
|
|
||||||
for alloc in ipam_allocations:
|
for alloc in ipam_allocations:
|
||||||
if alloc.proxmox_server_id and alloc.proxmox_vmid:
|
if alloc.proxmox_server_id and alloc.proxmox_vmid:
|
||||||
ipam_by_server_vmid[(alloc.proxmox_server_id, alloc.proxmox_vmid)] = alloc
|
key = (alloc.proxmox_server_id, alloc.proxmox_vmid)
|
||||||
if alloc.hostname:
|
current = ipam_by_server_vmid.get(key)
|
||||||
ipam_by_name[alloc.hostname.lower()] = alloc
|
if current is None or (alloc.is_primary and not current.is_primary):
|
||||||
if alloc.resource_name:
|
ipam_by_server_vmid[key] = alloc
|
||||||
ipam_by_name[alloc.resource_name.lower()] = alloc
|
|
||||||
|
|
||||||
# Detect clusters: servers that see multiple nodes are part of a cluster
|
# Detect clusters: servers that see multiple nodes are part of a cluster
|
||||||
# Group servers by their node set
|
# Group servers by their node set
|
||||||
server_nodes = {} # server_id -> set of node names
|
server_nodes = {} # server_id -> set of node names
|
||||||
@@ -749,26 +757,34 @@ class MonitoringWorker:
|
|||||||
all_seen.add((server_id, vmid))
|
all_seen.add((server_id, vmid))
|
||||||
|
|
||||||
# Get IP from IPAM
|
# Get IP from IPAM
|
||||||
ipam_alloc = (
|
ipam_alloc = ipam_by_server_vmid.get((server_id, vmid))
|
||||||
ipam_by_server_vmid.get((server_id, vmid)) or
|
|
||||||
ipam_by_name.get(vm_name.lower())
|
|
||||||
)
|
|
||||||
ip_address = ipam_alloc.ip_address if ipam_alloc else None
|
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 no IPAM IP, try to detect from Proxmox (LXC config or QEMU guest agent).
|
||||||
if not ip_address:
|
# Троттлим: адрес гостя меняется куда реже, чем идёт синк.
|
||||||
|
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:
|
try:
|
||||||
px_client = server_clients.get(server_id)
|
px_client = server_clients.get(server_id)
|
||||||
if px_client and node_name:
|
if px_client and node_name:
|
||||||
if vm_type == 'lxc':
|
if vm_type == 'lxc':
|
||||||
config = px_client.proxmox.nodes(node_name).lxc(vmid).config.get()
|
# Статика из netN + живые адреса запущенного
|
||||||
for i in range(4):
|
# контейнера (DHCP виден только там)
|
||||||
ipconfig = config.get(f'ipconfig{i}', '')
|
for iface in px_client.get_container_interfaces(
|
||||||
if 'ip=' in str(ipconfig):
|
node_name, vmid,
|
||||||
ip_part = str(ipconfig).split('ip=')[1].split(',')[0]
|
include_live=vm_data.get('status') == 'running',
|
||||||
if ip_part and ip_part != 'dhcp':
|
):
|
||||||
ip_address = ip_part.split('/')[0] if '/' in ip_part else ip_part
|
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
|
break
|
||||||
|
if ip_address:
|
||||||
|
break
|
||||||
elif vm_type == 'qemu' and vm_data.get('status') == 'running':
|
elif vm_type == 'qemu' and vm_data.get('status') == 'running':
|
||||||
result = px_client.proxmox.nodes(node_name).qemu(vmid).agent('network-get-interfaces').get()
|
result = px_client.proxmox.nodes(node_name).qemu(vmid).agent('network-get-interfaces').get()
|
||||||
if result and 'result' in result:
|
if result and 'result' in result:
|
||||||
@@ -862,6 +878,11 @@ class MonitoringWorker:
|
|||||||
db.rollback()
|
db.rollback()
|
||||||
return
|
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
|
# Mark VMs that no longer exist as deleted
|
||||||
# Get all active VMs from synced servers
|
# Get all active VMs from synced servers
|
||||||
synced_server_ids = set(server.id for server in 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")
|
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):
|
def migrate_setting_view_panel_only(conn):
|
||||||
"""Migration 42: drop setting:view where it was only granting the profile page.
|
"""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}")
|
logger.warning(f"setting:view split migration: {e}")
|
||||||
conn.rollback()
|
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("=" * 50)
|
||||||
logger.info("✓ All migrations completed successfully")
|
logger.info("✓ All migrations completed successfully")
|
||||||
logger.info("=" * 50)
|
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"] == ""
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Plus, Pencil, Trash2, Database, CalendarClock, Power, PowerOff, AlertTriangle } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Database, CalendarClock, Power, PowerOff, AlertTriangle, Search } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
useBackupStorages, useProxmoxBackupJobs,
|
useBackupStorages, useProxmoxBackupJobs,
|
||||||
useCreateStorage, useUpdateStorage, useDeleteStorage,
|
useCreateStorage, useUpdateStorage, useDeleteStorage,
|
||||||
useCreateProxmoxJob, useUpdateProxmoxJob, useDeleteProxmoxJob,
|
useCreateProxmoxJob, useUpdateProxmoxJob, useDeleteProxmoxJob,
|
||||||
|
useScanNfsExports,
|
||||||
type ProxmoxJobInput,
|
type ProxmoxJobInput,
|
||||||
} from '@/hooks/use-backups';
|
} from '@/hooks/use-backups';
|
||||||
import { formatBytes } from '@/lib/format';
|
import { formatBytes } from '@/lib/format';
|
||||||
@@ -88,6 +89,8 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const createStorage = useCreateStorage(serverId);
|
const createStorage = useCreateStorage(serverId);
|
||||||
const updateStorage = useUpdateStorage(serverId);
|
const updateStorage = useUpdateStorage(serverId);
|
||||||
|
const scanNfs = useScanNfsExports(serverId);
|
||||||
|
const [nfsExports, setNfsExports] = useState<string[]>([]);
|
||||||
const [form, setForm] = useState<StorageForm>(() => editing ? {
|
const [form, setForm] = useState<StorageForm>(() => editing ? {
|
||||||
...emptyForm,
|
...emptyForm,
|
||||||
storage: editing.storage,
|
storage: editing.storage,
|
||||||
@@ -102,6 +105,20 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
|||||||
} : emptyForm);
|
} : emptyForm);
|
||||||
const set = <K extends keyof StorageForm>(k: K, v: StorageForm[K]) => setForm(p => ({ ...p, [k]: v }));
|
const set = <K extends keyof StorageForm>(k: K, v: StorageForm[K]) => setForm(p => ({ ...p, [k]: v }));
|
||||||
|
|
||||||
|
const handleScanNfs = () => {
|
||||||
|
if (!form.server.trim()) { toast.error(t('storages.server_required')); return; }
|
||||||
|
setNfsExports([]);
|
||||||
|
scanNfs.mutate(form.server.trim(), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const paths = (data.exports ?? []).map(e => e.path).filter(Boolean);
|
||||||
|
setNfsExports(paths);
|
||||||
|
if (paths.length === 0) toast.error(t('storages.no_exports_found'));
|
||||||
|
else if (!paths.includes(form.export)) set('export', paths[0]);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!editing && !form.storage.trim()) { toast.error(t('storages.id_required')); return; }
|
if (!editing && !form.storage.trim()) { toast.error(t('storages.id_required')); return; }
|
||||||
const done = (msg: string) => { toast.success(msg); onOpenChange(false); };
|
const done = (msg: string) => { toast.success(msg); onOpenChange(false); };
|
||||||
@@ -185,8 +202,27 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
|||||||
)}
|
)}
|
||||||
{!editing && form.type === 'nfs' && (
|
{!editing && form.type === 'nfs' && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div><Label>{t('storages.server')}</Label><Input value={form.server} onChange={e => set('server', e.target.value)} className="mt-1" placeholder="10.0.0.5" /></div>
|
<div>
|
||||||
<div><Label>{t('storages.export')}</Label><Input value={form.export} onChange={e => set('export', e.target.value)} className="mt-1" placeholder="/exports/backup" /></div>
|
<Label>{t('storages.server')}</Label>
|
||||||
|
<div className="mt-1 flex gap-1.5">
|
||||||
|
<Input value={form.server} onChange={e => { set('server', e.target.value); setNfsExports([]); }} placeholder="10.0.0.5" />
|
||||||
|
<Button type="button" variant="outline" size="icon" className="shrink-0" title={t('storages.scan_exports')}
|
||||||
|
disabled={scanNfs.isPending} onClick={handleScanNfs}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t('storages.export')}</Label>
|
||||||
|
{nfsExports.length > 0 ? (
|
||||||
|
<Select value={form.export} onValueChange={v => v && set('export', v)}>
|
||||||
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{nfsExports.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input value={form.export} onChange={e => set('export', e.target.value)} className="mt-1" placeholder="/exports/backup" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!editing && form.type === 'cifs' && (
|
{!editing && form.type === 'cifs' && (
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ interface VNCData {
|
|||||||
auth_ticket?: string;
|
auth_ticket?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сессия консоли переживает один проход эффекта: connect* асинхронны, а React
|
||||||
|
// StrictMode в dev монтирует эффект дважды. Без флага отмены вторая пара
|
||||||
|
// xterm/RFB ложится в тот же контейнер поверх первой — «осиротевший» терминал
|
||||||
|
// остаётся в DOM и занимает видимую область, из-за чего экран выглядит пустым.
|
||||||
|
interface SessionGuard {
|
||||||
|
cancelled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface RFBHandle {
|
interface RFBHandle {
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
sendCtrlAltDel: () => void;
|
sendCtrlAltDel: () => void;
|
||||||
@@ -108,12 +116,37 @@ export default function ConsolePage() {
|
|||||||
const vid = Number(vmid);
|
const vid = Number(vmid);
|
||||||
const power = usePowerAction(sid, vid, type, node);
|
const power = usePowerAction(sid, vid, type, node);
|
||||||
|
|
||||||
|
const guardRef = useRef<SessionGuard | null>(null);
|
||||||
|
|
||||||
|
// Освобождает ресурсы текущей сессии (RFB / xterm / WebSocket / keepalive).
|
||||||
|
const cleanupSession = useCallback(() => {
|
||||||
|
if (rfbRef.current) {
|
||||||
|
try { rfbRef.current.disconnect(); } catch { /* */ }
|
||||||
|
rfbRef.current = null;
|
||||||
|
}
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
if (keepaliveRef.current) {
|
||||||
|
clearInterval(keepaliveRef.current);
|
||||||
|
keepaliveRef.current = null;
|
||||||
|
}
|
||||||
|
if (termRef.current) {
|
||||||
|
const t = termRef.current as { terminal: { dispose: () => void }; cleanup: () => void };
|
||||||
|
t.cleanup();
|
||||||
|
t.terminal.dispose();
|
||||||
|
termRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ==================== VNC Console (QEMU) ====================
|
// ==================== VNC Console (QEMU) ====================
|
||||||
const connectVNC = useCallback(async () => {
|
const connectVNC = useCallback(async (guard: SessionGuard) => {
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.get<VNCData>(
|
const data = await apiClient.get<VNCData>(
|
||||||
`/proxmox/api/${sid}/vm/${vid}/vnc?node=${node}`
|
`/proxmox/api/${sid}/vm/${vid}/vnc?node=${node}`
|
||||||
);
|
);
|
||||||
|
if (guard.cancelled) return;
|
||||||
|
|
||||||
// Dynamic import noVNC. The package ships CJS, so depending on bundler
|
// Dynamic import noVNC. The package ships CJS, so depending on bundler
|
||||||
// interop the result can be: { default: RFB }, { default: { default: RFB, __esModule: true } },
|
// interop the result can be: { default: RFB }, { default: { default: RFB, __esModule: true } },
|
||||||
@@ -149,7 +182,7 @@ export default function ConsolePage() {
|
|||||||
|
|
||||||
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/vnc/${sid}/${data.node}/qemu/${vid}?${params}`;
|
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/vnc/${sid}/${data.node}/qemu/${vid}?${params}`;
|
||||||
|
|
||||||
if (!containerRef.current) return;
|
if (guard.cancelled || !containerRef.current) return;
|
||||||
|
|
||||||
// Pass URL string to RFB, NOT an open WebSocket
|
// Pass URL string to RFB, NOT an open WebSocket
|
||||||
const rfb = new RFB(containerRef.current, wsUrl, {
|
const rfb = new RFB(containerRef.current, wsUrl, {
|
||||||
@@ -160,10 +193,12 @@ export default function ConsolePage() {
|
|||||||
rfb.resizeSession = false;
|
rfb.resizeSession = false;
|
||||||
|
|
||||||
rfb.addEventListener('connect', () => {
|
rfb.addEventListener('connect', () => {
|
||||||
|
if (guard.cancelled) return;
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
rfb.addEventListener('disconnect', (e: CustomEvent) => {
|
rfb.addEventListener('disconnect', (e: CustomEvent) => {
|
||||||
|
if (guard.cancelled) return;
|
||||||
if (e.detail?.clean) {
|
if (e.detail?.clean) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMsg('Connection closed');
|
setErrorMsg('Connection closed');
|
||||||
@@ -176,6 +211,7 @@ export default function ConsolePage() {
|
|||||||
rfbRef.current = rfb as RFBHandle;
|
rfbRef.current = rfb as RFBHandle;
|
||||||
setScaleToFit(true);
|
setScaleToFit(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (guard.cancelled) return;
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||||
}
|
}
|
||||||
@@ -183,7 +219,7 @@ export default function ConsolePage() {
|
|||||||
|
|
||||||
// ==================== xterm.js session (LXC terminal / VM serial) ====================
|
// ==================== xterm.js session (LXC terminal / VM serial) ====================
|
||||||
// Общая обвязка xterm + WebSocket. wsPath — путь до нужного ws-эндпоинта Proxmox-прокси.
|
// Общая обвязка xterm + WebSocket. wsPath — путь до нужного ws-эндпоинта Proxmox-прокси.
|
||||||
const setupXtermSession = useCallback(async (wsPath: string) => {
|
const setupXtermSession = useCallback(async (wsPath: string, guard: SessionGuard) => {
|
||||||
// Dynamic import xterm.js
|
// Dynamic import xterm.js
|
||||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||||
import('@xterm/xterm'),
|
import('@xterm/xterm'),
|
||||||
@@ -191,7 +227,7 @@ export default function ConsolePage() {
|
|||||||
import('@xterm/addon-web-links'),
|
import('@xterm/addon-web-links'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!containerRef.current) return;
|
if (guard.cancelled || !containerRef.current) return;
|
||||||
|
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
const webLinksAddon = new WebLinksAddon();
|
const webLinksAddon = new WebLinksAddon();
|
||||||
@@ -219,9 +255,12 @@ export default function ConsolePage() {
|
|||||||
const wsUrl = `${wsProto}//${window.location.host}${wsPath}${tokenParam}`;
|
const wsUrl = `${wsProto}//${window.location.host}${wsPath}${tokenParam}`;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
ws.binaryType = 'arraybuffer';
|
ws.binaryType = 'arraybuffer';
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
if (guard.cancelled) {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
const { cols, rows } = terminal;
|
const { cols, rows } = terminal;
|
||||||
ws.send(`1:${cols}:${rows}:`);
|
ws.send(`1:${cols}:${rows}:`);
|
||||||
@@ -231,11 +270,20 @@ export default function ConsolePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
|
if (guard.cancelled) return;
|
||||||
if (event.data instanceof ArrayBuffer) terminal.write(new Uint8Array(event.data));
|
if (event.data instanceof ArrayBuffer) terminal.write(new Uint8Array(event.data));
|
||||||
else terminal.write(event.data);
|
else terminal.write(event.data);
|
||||||
};
|
};
|
||||||
ws.onclose = () => { setStatus('error'); setErrorMsg('Terminal connection closed'); };
|
ws.onclose = () => {
|
||||||
ws.onerror = () => { setStatus('error'); setErrorMsg('Terminal connection error'); };
|
if (guard.cancelled) return;
|
||||||
|
setStatus('error');
|
||||||
|
setErrorMsg('Terminal connection closed');
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (guard.cancelled) return;
|
||||||
|
setStatus('error');
|
||||||
|
setErrorMsg('Terminal connection error');
|
||||||
|
};
|
||||||
|
|
||||||
terminal.onData((data: string) => {
|
terminal.onData((data: string) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -249,62 +297,75 @@ export default function ConsolePage() {
|
|||||||
|
|
||||||
const handleResize = () => fitAddon.fit();
|
const handleResize = () => fitAddon.fit();
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Сессию могли отменить, пока грузились чанки xterm. Убираем за собой сами и
|
||||||
|
// НЕ трогаем refs: они уже могут указывать на актуальную сессию. Иначе в
|
||||||
|
// контейнере остаётся невидимый терминал поверх живого — чёрный экран.
|
||||||
|
if (guard.cancelled) {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
terminal.dispose();
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wsRef.current = ws;
|
||||||
termRef.current = { terminal, fitAddon, cleanup: () => window.removeEventListener('resize', handleResize) };
|
termRef.current = { terminal, fitAddon, cleanup: () => window.removeEventListener('resize', handleResize) };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ==================== Terminal Console (LXC) ====================
|
// ==================== Terminal Console (LXC) ====================
|
||||||
const connectTerminal = useCallback(async () => {
|
const connectTerminal = useCallback(async (guard: SessionGuard) => {
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.get<VNCData>(
|
// Termproxy-сессию создаёт сам ws-эндпоинт. Отдельный REST-вызов
|
||||||
`/proxmox/api/${sid}/container/${vid}/terminal?node=${node}`
|
// (GET .../container/{vmid}/terminal) поднимал бы на ноде вторую
|
||||||
);
|
// termproxy-сессию, к которой никто не подключается: Proxmox ждёт клиента
|
||||||
await setupXtermSession(`/proxmox/ws/terminal/${sid}/${data.node}/${vid}`);
|
// ~10 секунд и завершает задачу с «failed waiting for client: timed out».
|
||||||
|
await setupXtermSession(`/proxmox/ws/terminal/${sid}/${node}/${vid}`, guard);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (guard.cancelled) return;
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||||
}
|
}
|
||||||
}, [sid, vid, node, setupXtermSession]);
|
}, [sid, vid, node, setupXtermSession]);
|
||||||
|
|
||||||
// ==================== Serial Console (QEMU) ====================
|
// ==================== Serial Console (QEMU) ====================
|
||||||
const connectSerial = useCallback(async () => {
|
const connectSerial = useCallback(async (guard: SessionGuard) => {
|
||||||
try {
|
try {
|
||||||
// Убедиться, что у VM есть serial0 (добавит при отсутствии)
|
// Убедиться, что у VM есть serial0 (добавит при отсутствии)
|
||||||
await apiClient.post(`/proxmox/api/${sid}/vm/${vid}/serial/enable?node=${node}`);
|
await apiClient.post(`/proxmox/api/${sid}/vm/${vid}/serial/enable?node=${node}`);
|
||||||
await setupXtermSession(`/proxmox/ws/serial/${sid}/${node}/${vid}`);
|
if (guard.cancelled) return;
|
||||||
|
await setupXtermSession(`/proxmox/ws/serial/${sid}/${node}/${vid}`, guard);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (guard.cancelled) return;
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||||
}
|
}
|
||||||
}, [sid, vid, node, setupXtermSession]);
|
}, [sid, vid, node, setupXtermSession]);
|
||||||
|
|
||||||
// ==================== Connect on mount ====================
|
// ==================== Connect on mount ====================
|
||||||
|
// Старт новой сессии: старая помечается отменённой и полностью убирается,
|
||||||
|
// чтобы в контейнере всегда был ровно один живой терминал/RFB.
|
||||||
|
const startSession = useCallback(() => {
|
||||||
|
if (guardRef.current) guardRef.current.cancelled = true;
|
||||||
|
cleanupSession();
|
||||||
|
|
||||||
|
const guard: SessionGuard = { cancelled: false };
|
||||||
|
guardRef.current = guard;
|
||||||
|
setStatus('connecting');
|
||||||
|
setErrorMsg('');
|
||||||
|
|
||||||
|
if (isSerial) connectSerial(guard);
|
||||||
|
else if (type === 'qemu') connectVNC(guard);
|
||||||
|
else connectTerminal(guard);
|
||||||
|
}, [isSerial, type, connectSerial, connectVNC, connectTerminal, cleanupSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSerial) {
|
startSession();
|
||||||
connectSerial();
|
|
||||||
} else if (type === 'qemu') {
|
|
||||||
connectVNC();
|
|
||||||
} else {
|
|
||||||
connectTerminal();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Cleanup
|
if (guardRef.current) guardRef.current.cancelled = true;
|
||||||
if (rfbRef.current) {
|
cleanupSession();
|
||||||
try { (rfbRef.current as { disconnect: () => void }).disconnect(); } catch { /* */ }
|
|
||||||
}
|
|
||||||
if (wsRef.current) {
|
|
||||||
wsRef.current.close();
|
|
||||||
}
|
|
||||||
if (keepaliveRef.current) {
|
|
||||||
clearInterval(keepaliveRef.current);
|
|
||||||
}
|
|
||||||
if (termRef.current) {
|
|
||||||
const t = termRef.current as { terminal: { dispose: () => void }; cleanup: () => void };
|
|
||||||
t.cleanup();
|
|
||||||
t.terminal.dispose();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [type, isSerial, connectVNC, connectTerminal, connectSerial]);
|
}, [startSession, cleanupSession]);
|
||||||
|
|
||||||
// ==================== Fullscreen ====================
|
// ==================== Fullscreen ====================
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
@@ -574,13 +635,7 @@ export default function ConsolePage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={startSession}
|
||||||
setStatus('connecting');
|
|
||||||
setErrorMsg('');
|
|
||||||
if (isSerial) connectSerial();
|
|
||||||
else if (type === 'qemu') connectVNC();
|
|
||||||
else connectTerminal();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('console.reconnect')}
|
{t('console.reconnect')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -25,10 +25,30 @@ export default function NodeShellPage() {
|
|||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const keepaliveRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const keepaliveRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const termRef = useRef<{ terminal: { dispose: () => void }; cleanup: () => void } | null>(null);
|
const termRef = useRef<{ terminal: { dispose: () => void }; cleanup: () => void } | null>(null);
|
||||||
|
// Флаг отмены: connect() асинхронен, а React StrictMode в dev монтирует эффект
|
||||||
|
// дважды — без него второй xterm ложится в контейнер поверх первого и видимой
|
||||||
|
// остаётся «осиротевшая» пустая сессия.
|
||||||
|
const guardRef = useRef<{ cancelled: boolean } | null>(null);
|
||||||
|
|
||||||
const sid = Number(serverId);
|
const sid = Number(serverId);
|
||||||
|
|
||||||
const connect = useCallback(async () => {
|
const cleanupSession = useCallback(() => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
if (keepaliveRef.current) {
|
||||||
|
clearInterval(keepaliveRef.current);
|
||||||
|
keepaliveRef.current = null;
|
||||||
|
}
|
||||||
|
if (termRef.current) {
|
||||||
|
termRef.current.cleanup();
|
||||||
|
termRef.current.terminal.dispose();
|
||||||
|
termRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback(async (guard: { cancelled: boolean }) => {
|
||||||
try {
|
try {
|
||||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||||
import('@xterm/xterm'),
|
import('@xterm/xterm'),
|
||||||
@@ -36,7 +56,7 @@ export default function NodeShellPage() {
|
|||||||
import('@xterm/addon-web-links'),
|
import('@xterm/addon-web-links'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!containerRef.current) return;
|
if (guard.cancelled || !containerRef.current) return;
|
||||||
|
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
const webLinksAddon = new WebLinksAddon();
|
const webLinksAddon = new WebLinksAddon();
|
||||||
@@ -65,9 +85,12 @@ export default function NodeShellPage() {
|
|||||||
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/node-shell/${sid}/${node}?${params}`;
|
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/node-shell/${sid}/${node}?${params}`;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
ws.binaryType = 'arraybuffer';
|
ws.binaryType = 'arraybuffer';
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
if (guard.cancelled) {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
const { cols, rows } = terminal;
|
const { cols, rows } = terminal;
|
||||||
ws.send(`1:${cols}:${rows}:`);
|
ws.send(`1:${cols}:${rows}:`);
|
||||||
@@ -77,6 +100,7 @@ export default function NodeShellPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
|
if (guard.cancelled) return;
|
||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
terminal.write(new Uint8Array(event.data));
|
terminal.write(new Uint8Array(event.data));
|
||||||
} else {
|
} else {
|
||||||
@@ -85,10 +109,12 @@ export default function NodeShellPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
if (guard.cancelled) return;
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMsg(t('console.terminal_closed', 'Соединение с терминалом закрыто'));
|
setErrorMsg(t('console.terminal_closed', 'Соединение с терминалом закрыто'));
|
||||||
};
|
};
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
|
if (guard.cancelled) return;
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMsg(t('console.terminal_error', 'Ошибка соединения с терминалом'));
|
setErrorMsg(t('console.terminal_error', 'Ошибка соединения с терминалом'));
|
||||||
};
|
};
|
||||||
@@ -106,24 +132,43 @@ export default function NodeShellPage() {
|
|||||||
|
|
||||||
const handleResize = () => fitAddon.fit();
|
const handleResize = () => fitAddon.fit();
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Сессию могли отменить, пока грузились чанки xterm. Убираем за собой сами
|
||||||
|
// и не трогаем refs — они уже могут указывать на актуальную сессию.
|
||||||
|
if (guard.cancelled) {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
terminal.dispose();
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wsRef.current = ws;
|
||||||
termRef.current = { terminal, cleanup: () => window.removeEventListener('resize', handleResize) };
|
termRef.current = { terminal, cleanup: () => window.removeEventListener('resize', handleResize) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (guard.cancelled) return;
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||||
}
|
}
|
||||||
}, [sid, node, cmd, t]);
|
}, [sid, node, cmd, t]);
|
||||||
|
|
||||||
|
const startSession = useCallback(() => {
|
||||||
|
if (guardRef.current) guardRef.current.cancelled = true;
|
||||||
|
cleanupSession();
|
||||||
|
|
||||||
|
const guard = { cancelled: false };
|
||||||
|
guardRef.current = guard;
|
||||||
|
setStatus('connecting');
|
||||||
|
setErrorMsg('');
|
||||||
|
connect(guard);
|
||||||
|
}, [connect, cleanupSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connect();
|
startSession();
|
||||||
return () => {
|
return () => {
|
||||||
if (wsRef.current) wsRef.current.close();
|
if (guardRef.current) guardRef.current.cancelled = true;
|
||||||
if (keepaliveRef.current) clearInterval(keepaliveRef.current);
|
cleanupSession();
|
||||||
if (termRef.current) {
|
|
||||||
termRef.current.cleanup();
|
|
||||||
termRef.current.terminal.dispose();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [connect]);
|
}, [startSession, cleanupSession]);
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
@@ -182,11 +227,7 @@ export default function NodeShellPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={startSession}
|
||||||
setStatus('connecting');
|
|
||||||
setErrorMsg('');
|
|
||||||
connect();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('console.reconnect', 'Переподключиться')}
|
{t('console.reconnect', 'Переподключиться')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
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',
|
id: 'ip',
|
||||||
accessorKey: 'ip_address',
|
// Поиск идёт по всем адресам гостя, а показывается основной
|
||||||
|
accessorFn: (vm) => [vm.ip_address, ...(vm.ips ?? []).map((a) => a.ip)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
filterFn: 'includesString',
|
filterFn: 'includesString',
|
||||||
meta: { filter: 'text' },
|
meta: { filter: 'text' },
|
||||||
header: 'IP',
|
header: 'IP',
|
||||||
cell: ({ getValue }) => {
|
cell: ({ row }) => {
|
||||||
const ip = getValue<string>();
|
const vm = row.original;
|
||||||
return ip ? (
|
const primary = vm.ip_address;
|
||||||
<span className="font-mono text-xs">{ip}</span>
|
const extra = (vm.ips ?? []).filter((a) => a.ip !== primary);
|
||||||
) : (
|
if (!primary) {
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
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,
|
size: 130,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -13,9 +13,17 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useVMInterfaces, useVMConfig, useVMStatus, useUpdateConfig } from '@/hooks/use-instances';
|
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 { formatBytes } from '@/lib/format';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import AddNetworkInterfaceDialog from '../AddNetworkInterfaceDialog';
|
import AddNetworkInterfaceDialog from '../AddNetworkInterfaceDialog';
|
||||||
|
import AddGuestIPDialog from '../AddGuestIPDialog';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: number;
|
serverId: number;
|
||||||
@@ -41,6 +49,23 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
|||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.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 = () => {
|
const removeNic = () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
updateConfig.mutate(
|
updateConfig.mutate(
|
||||||
@@ -73,6 +98,9 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* IPAM addresses of this guest */}
|
||||||
|
<GuestAddressesCard serverId={serverId} vmid={vmid} interfaces={guestInterfaces} />
|
||||||
|
|
||||||
{/* Configured network devices */}
|
{/* Configured network devices */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
@@ -199,3 +227,213 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
|||||||
</div>
|
</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.ip_address?.toLowerCase().includes(s) ||
|
||||||
a.resource_name?.toLowerCase().includes(s) ||
|
a.resource_name?.toLowerCase().includes(s) ||
|
||||||
a.mac_address?.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;
|
return true;
|
||||||
@@ -108,12 +110,25 @@ export default function IPAMAllocationsPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.map(a => (
|
{filtered.map(a => (
|
||||||
<TableRow key={a.id}>
|
<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>
|
<TableCell>
|
||||||
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
|
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">{a.network_name || '—'}</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="font-mono text-xs">{a.mac_address || '—'}</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">{a.description || '—'}</TableCell>
|
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">{a.description || '—'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -235,7 +235,20 @@ function UnlinkedCard() {
|
|||||||
|
|
||||||
const handleLink = () => {
|
const handleLink = () => {
|
||||||
link.mutate(undefined, {
|
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),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
import { Box, Monitor } from 'lucide-react';
|
import { Box, Monitor } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -14,9 +15,10 @@ function GuestNodeImpl({ data }: NodeProps) {
|
|||||||
const extraTags = guest.tags.length - VISIBLE_TAGS;
|
const extraTags = guest.tags.length - VISIBLE_TAGS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Link
|
||||||
|
to={`/instances/${guest.server_id}/${guest.vmid}?node=${guest.node}&type=${guest.type}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[250px] rounded-md border bg-card px-2.5 py-2 shadow-sm',
|
'block w-[250px] rounded-md border bg-card px-2.5 py-2 shadow-sm transition-colors hover:border-primary/60 hover:bg-accent/40',
|
||||||
running ? 'border-emerald-500/50' : 'border-border',
|
running ? 'border-emerald-500/50' : 'border-border',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -47,7 +49,7 @@ function GuestNodeImpl({ data }: NodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!bg-border" />
|
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!bg-border" />
|
||||||
</div>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ export function useDeleteProxmoxJob(serverId: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useScanNfsExports(serverId: number) {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (server: string) =>
|
||||||
|
apiClient.get<{ exports: { path: string }[] }>(
|
||||||
|
`/proxmox/api/backups/scan-nfs/${serverId}?server=${encodeURIComponent(server)}`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateStorage(serverId: number) {
|
export function useCreateStorage(serverId: number) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api-client';
|
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 = {
|
export const ipamKeys = {
|
||||||
networks: ['ipam-networks'] as const,
|
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() {
|
export function useLinkAllocations() {
|
||||||
const invalidate = useInvalidateIPAM();
|
const invalidate = useInvalidateIPAM();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body?: { server_id?: number; network_id?: number }) =>
|
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(),
|
onSuccess: () => invalidate(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -283,3 +295,95 @@ export function useIPHistoryLookup() {
|
|||||||
mutationFn: (ip: string) => apiClient.get<unknown[]>(`/ipam/api/history/ip/${encodeURIComponent(ip)}`),
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1246,6 +1246,9 @@
|
|||||||
"path": "Path",
|
"path": "Path",
|
||||||
"server": "Server",
|
"server": "Server",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
|
"scan_exports": "Scan server for exports",
|
||||||
|
"server_required": "Enter the server address first",
|
||||||
|
"no_exports_found": "No exports found — check /etc/exports on the server",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
"datastore": "Datastore",
|
"datastore": "Datastore",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
@@ -1416,6 +1419,7 @@
|
|||||||
"no_unlinked": "All VMs are linked",
|
"no_unlinked": "All VMs are linked",
|
||||||
"link_allocations": "Scan & link",
|
"link_allocations": "Scan & link",
|
||||||
"linked": "Linked",
|
"linked": "Linked",
|
||||||
|
"link_skipped": "Skipped: {{count}}",
|
||||||
"ip_history": "IP history lookup",
|
"ip_history": "IP history lookup",
|
||||||
"pool_required_hint": "Auto-allocation requires an auto-assign pool in the network.",
|
"pool_required_hint": "Auto-allocation requires an auto-assign pool in the network.",
|
||||||
"resource_required": "Please specify a resource before allocating an IP.",
|
"resource_required": "Please specify a resource before allocating an IP.",
|
||||||
@@ -1480,7 +1484,40 @@
|
|||||||
"allocation_created": "Allocation created",
|
"allocation_created": "Allocation created",
|
||||||
"allocation_updated": "Allocation updated",
|
"allocation_updated": "Allocation updated",
|
||||||
"delete_allocation_confirm": "Release IP {{ip}}?",
|
"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": {
|
"logs": {
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
|
|||||||
@@ -1228,6 +1228,9 @@
|
|||||||
"path": "Путь",
|
"path": "Путь",
|
||||||
"server": "Сервер",
|
"server": "Сервер",
|
||||||
"export": "Экспорт",
|
"export": "Экспорт",
|
||||||
|
"scan_exports": "Найти экспорты на сервере",
|
||||||
|
"server_required": "Сначала укажите адрес сервера",
|
||||||
|
"no_exports_found": "Экспорты не найдены — проверьте /etc/exports на сервере",
|
||||||
"share": "Шара",
|
"share": "Шара",
|
||||||
"datastore": "Datastore",
|
"datastore": "Datastore",
|
||||||
"username": "Пользователь",
|
"username": "Пользователь",
|
||||||
@@ -1398,6 +1401,7 @@
|
|||||||
"no_unlinked": "Все VM связаны",
|
"no_unlinked": "Все VM связаны",
|
||||||
"link_allocations": "Сканировать и связать",
|
"link_allocations": "Сканировать и связать",
|
||||||
"linked": "Связано",
|
"linked": "Связано",
|
||||||
|
"link_skipped": "Пропущено: {{count}}",
|
||||||
"ip_history": "История по IP",
|
"ip_history": "История по IP",
|
||||||
"pool_required_hint": "Авто-выделение требует пул с auto-assign в сети.",
|
"pool_required_hint": "Авто-выделение требует пул с auto-assign в сети.",
|
||||||
"resource_required": "Укажите ресурс перед выделением IP.",
|
"resource_required": "Укажите ресурс перед выделением IP.",
|
||||||
@@ -1462,7 +1466,40 @@
|
|||||||
"allocation_created": "Аллокация создана",
|
"allocation_created": "Аллокация создана",
|
||||||
"allocation_updated": "Аллокация обновлена",
|
"allocation_updated": "Аллокация обновлена",
|
||||||
"delete_allocation_confirm": "Освободить IP {{ip}}?",
|
"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": {
|
"logs": {
|
||||||
"level": "Уровень",
|
"level": "Уровень",
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ export interface VMInstance {
|
|||||||
netin_rate?: number;
|
netin_rate?: number;
|
||||||
netout_rate?: number;
|
netout_rate?: number;
|
||||||
ip_address?: string;
|
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;
|
tags?: string;
|
||||||
template?: boolean;
|
template?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -397,10 +405,41 @@ export interface IPAMAllocation {
|
|||||||
fqdn?: string;
|
fqdn?: string;
|
||||||
allocation_type?: string;
|
allocation_type?: string;
|
||||||
notes?: 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;
|
created_at: string;
|
||||||
updated_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 ====================
|
// ==================== Template Types ====================
|
||||||
|
|
||||||
export interface OSTemplateGroup {
|
export interface OSTemplateGroup {
|
||||||
|
|||||||
5
init.sql
5
init.sql
@@ -507,7 +507,6 @@ INSERT INTO roles (name, display_name, description, permissions, is_system, is_a
|
|||||||
"template:view": true,
|
"template:view": true,
|
||||||
"storage:view": true,
|
"storage:view": true,
|
||||||
"backup:view": true, "backup:create": true,
|
"backup:view": true, "backup:create": true,
|
||||||
"ipam:view": true,
|
|
||||||
"user:view": true,
|
"user:view": true,
|
||||||
"log:view": true, "log:export": true,
|
"log:view": true, "log:export": true,
|
||||||
"setting:view": true,
|
"setting:view": true,
|
||||||
@@ -523,7 +522,6 @@ INSERT INTO roles (name, display_name, description, permissions, is_system, is_a
|
|||||||
"lxc:view": true, "lxc:start": true, "lxc:stop": true, "lxc:restart": true, "lxc:console": true,
|
"lxc:view": true, "lxc:start": true, "lxc:stop": true, "lxc:restart": true, "lxc:console": true,
|
||||||
"template:view": true,
|
"template:view": true,
|
||||||
"storage:view": true,
|
"storage:view": true,
|
||||||
"ipam:view": true,
|
|
||||||
"notification:view": true, "notification:manage": true
|
"notification:view": true, "notification:manage": true
|
||||||
}', TRUE, TRUE),
|
}', TRUE, TRUE),
|
||||||
('demo', 'Demo User', 'Read-only access for demonstration', '{
|
('demo', 'Demo User', 'Read-only access for demonstration', '{
|
||||||
@@ -532,8 +530,7 @@ INSERT INTO roles (name, display_name, description, permissions, is_system, is_a
|
|||||||
"vm:view": true,
|
"vm:view": true,
|
||||||
"lxc:view": true,
|
"lxc:view": true,
|
||||||
"template:view": true,
|
"template:view": true,
|
||||||
"storage:view": true,
|
"storage:view": true
|
||||||
"ipam:view": true
|
|
||||||
}', TRUE, TRUE)
|
}', TRUE, TRUE)
|
||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user