Compare commits
20 Commits
v1.17.0
...
1d7b22f4d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d7b22f4d1 | |||
| f56c46b587 | |||
| 6bfaec42c9 | |||
| 7fcb5eb7c6 | |||
| 42871ee64f | |||
|
|
33e5620311 | ||
|
|
d5c845f4d3 | ||
| abc819294c | |||
| d80623c0d8 | |||
| 972d54d1f1 | |||
| 6ac505e481 | |||
| 6796903712 | |||
| c9d8e8207f | |||
| f9425082f7 | |||
| dc53c415db | |||
| 7d60afb9fa | |||
| 30e92da8b5 | |||
|
|
4f290b92fb | ||
|
|
295d3fc06e | ||
|
|
31d17fca61 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -87,3 +87,6 @@ docs/superpowers/
|
||||
|
||||
# Планы разработки не коммитим в репозиторий
|
||||
docs/plans/
|
||||
|
||||
# Сводки сессий (локальные, не для репозитория)
|
||||
docs/summaries/
|
||||
|
||||
56
CHANGELOG.md
56
CHANGELOG.md
@@ -4,6 +4,62 @@ All notable changes to PVEmanager will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **`guest-exec` is unblocked in the Rocky Linux, AlmaLinux, CentOS Stream and Fedora images** — the RHEL-family `qemu-guest-agent` package runs the agent with an allow-list that omits `guest-exec`, `guest-exec-status` and `guest-file-*` (`FILTER_RPC_ARGS` in `/etc/sysconfig/qemu-ga`; older packages expressed the same thing through `BLOCK_RPCS`/`BLACKLIST_RPC`). Because of it, script execution, extra IP addresses and filesystem growth after a disk resize did not work in such VMs
|
||||
- **Five mirror images were rebuilt** — release `2026-08-22`, and the bundled catalog now points at it; Ubuntu and Debian are untouched and stay in the previous releases, so older panel versions keep working
|
||||
- **`patch-cloud-images.sh` clears the restriction while building** (turn it off with `UNBLOCK_RPC=0`) and can build part of the catalog — `ONLY="rocky alma centos fedora"`. And when the panel hits the block, it spells out what to fix inside the guest
|
||||
|
||||
---
|
||||
|
||||
## [v1.18.0] - 2026-08-22
|
||||
|
||||
### 🌐 Several IP addresses per instance
|
||||
|
||||
- **An instance is no longer limited to one address** — a guest can hold a primary address plus any number of extra ones. Previously every lookup assumed "one guest → one allocation": `find_allocation_by_resource()` returned the first row it saw, `release_ip_by_vmid()` freed a single address (leaving the rest occupied forever after a guest was deleted), and the link tool skipped guests that already had an allocation
|
||||
- **New "IP addresses" card on the instance Network tab** — lists every address of the guest with its state, and lets you add one (next free in a network/pool or entered by hand), retry applying it, mark it primary or release it. The IP column of the instance list shows the primary address with a **+N** badge for the rest, and the allocations table marks the primary one
|
||||
- **Extra addresses are applied inside the guest as aliases** — through the existing script engine, so LXC goes over `pct exec` and VMs over the QEMU guest agent. The address is brought up with `ip addr add` and never restarts guest networking, so a running guest keeps its connectivity
|
||||
- **Addresses survive a guest reboot** — Proxmox regenerates the guest network configuration on every start (pve-container rewrites `/etc/network/interfaces`, `ifcfg` or the NetworkManager keyfile from `netN`), which wiped any alias added to the native stack. A small oneshot systemd unit re-adds the recorded addresses after `network-online.target`; `/etc/systemd` is not touched by Proxmox. The native stacks (ifupdown, netplan, NetworkManager, sysconfig/network-scripts for RHEL-based guests) are still written, so the reported state can be composite, e.g. `nm+systemd`
|
||||
- **An address is reserved even when it cannot be applied** — a stopped guest, a missing guest agent or no SSH access to the node leaves the allocation in `pending` with the reason shown, and the **Apply** button retries it later. An unrecognised network stack degrades to `runtime_only`, which warns that the address will not survive a reboot
|
||||
- **New endpoints** — `GET/POST /ipam/api/guests/{server_id}/{vmid}/addresses`, `POST …/{allocation_id}/apply`, `POST …/{allocation_id}/primary`, `DELETE …/{allocation_id}`; the allocations list accepts `proxmox_server_id` / `proxmox_vmid` filters, and the instance list exposes `ips[]` next to the primary `ip`
|
||||
- **Migration 43** adds `is_primary`, `assignment_kind`, `target_interface` and the `apply_*` columns to `ipam_allocations` and marks existing rows as primary. The first address of a guest is now flagged automatically, no matter which of the six allocation paths issued it
|
||||
- **Releasing an address removes it from the guest first**, then frees it in IPAM; the primary address stays owned by the NIC config and is not releasable from this card
|
||||
|
||||
### 🔍 IPAM address discovery fixes
|
||||
|
||||
- **LXC containers finally show their IP** — `get_container_interfaces()` looked for `ipconfigN` keys, which exist only on QEMU cloud-init guests. LXC keeps the address inside the `netN` string, so the method never returned anything and the IP column stayed empty for every container. The `netN` parser is used now, with a fallback to `/nodes/{node}/lxc/{vmid}/interfaces` for DHCP guests
|
||||
- **"Scan & link" registers every address of a guest**, not just the first one, and no longer skips guests that already have an allocation — a changed or added address used to stay unnoticed forever
|
||||
- **The MAC address is carried over** into the allocation when linking, taken from the interface the address was found on
|
||||
- **A duplicate address can no longer be stolen from a live guest** — when two guests advertise the same IP (typically a stale `ip=` left in the config of a stopped container), the owner is kept and the conflict is reported instead of silently reassigning the record
|
||||
- **Allocations are no longer matched by resource name** — two guests named alike on different servers made one guest display the other's address
|
||||
- **IPAM history no longer fails on datetime values**, which used to abort re-linking with a 500
|
||||
- **Guest IP is re-checked at most once every 5 minutes per guest** instead of on every cache sync (every 10 s), removing hundreds of pointless Proxmox calls per minute
|
||||
|
||||
### 🐛 Other fixes
|
||||
|
||||
- **Scripts with non-ASCII text run on VMs again** — Proxmox base64-encodes the guest agent payload in Perl, which dies with `Wide character in subroutine entry` as soon as the script contains non-ASCII (a Cyrillic comment is enough). The payload is now handed over as bytes
|
||||
- **Next-free-IP search can scan the whole subnet** when a network has no auto-assign pool, skipping node addresses and cached guest addresses so it never hands out an address that is already live
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 🖥️ 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
|
||||
|
||||
### 💾 Move LXC container disks to another storage
|
||||
|
||||
@@ -4,6 +4,62 @@
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 🐛 Исправления
|
||||
|
||||
- **В образах Rocky Linux, AlmaLinux, CentOS Stream и Fedora разблокирован `guest-exec`** — пакет `qemu-guest-agent` в RHEL-семействе запускает агента с allow-list, в котором нет `guest-exec`, `guest-exec-status` и `guest-file-*` (`FILTER_RPC_ARGS` в `/etc/sysconfig/qemu-ga`; в пакетах постарше то же самое задавалось через `BLOCK_RPCS`/`BLACKLIST_RPC`). Из-за этого в таких ВМ не работали выполнение скриптов, выдача дополнительных IP и расширение ФС после ресайза диска
|
||||
- **Пять образов зеркала пересобраны** — релиз `2026-08-22`, встроенный каталог переключён на него; Ubuntu и Debian не затронуты и остаются в прежних релизах, поэтому старые версии панели продолжают работать
|
||||
- **`patch-cloud-images.sh` снимает ограничение при сборке** (отключается через `UNBLOCK_RPC=0`) и умеет собирать часть каталога — `ONLY="rocky alma centos fedora"`. Панель же, наткнувшись на запрет, объясняет в тексте ошибки, что и где поправить в госте
|
||||
|
||||
---
|
||||
|
||||
## [v1.18.0] - 2026-08-22
|
||||
|
||||
### 🌐 Несколько IP-адресов на инстанс
|
||||
|
||||
- **Инстанс больше не ограничен одним адресом** — у гостя может быть основной адрес и любое количество дополнительных. Раньше вся логика исходила из «один гость — одна аллокация»: `find_allocation_by_resource()` возвращал первую попавшуюся запись, `release_ip_by_vmid()` освобождал один адрес (остальные после удаления гостя оставались занятыми навсегда), а инструмент связывания пропускал гостей, у которых аллокация уже была
|
||||
- **На вкладке «Сеть» инстанса появилась карточка «IP-адреса»** — показывает все адреса гостя с их состоянием и позволяет выдать новый (следующий свободный в сети/пуле или введённый вручную), повторить применение, сделать основным или освободить. В колонке IP списка инстансов показывается основной адрес и бейдж **+N** для остальных, а в таблице аллокаций основной помечен
|
||||
- **Дополнительные адреса навешиваются внутри гостя как alias** — через существующий движок скриптов: для LXC это `pct exec`, для ВМ — QEMU guest agent. Адрес поднимается через `ip addr add`, сеть гостя при этом не перезапускается, поэтому работающий гость не теряет связь
|
||||
- **Адреса переживают перезагрузку гостя** — Proxmox пересоздаёт сетевую конфигурацию при каждом старте (pve-container переписывает `/etc/network/interfaces`, `ifcfg` или keyfile NetworkManager из `netN`), стирая любой alias, добавленный в родной стек. Небольшой oneshot systemd-юнит заново поднимает записанные адреса после `network-online.target`; каталог `/etc/systemd` Proxmox не трогает. Родные стеки (ifupdown, netplan, NetworkManager, sysconfig/network-scripts для RHEL-подобных гостей) по-прежнему настраиваются, поэтому состояние может быть составным, например `nm+systemd`
|
||||
- **Адрес резервируется, даже если применить его не удалось** — остановленный гость, отсутствующий guest agent или недоступный SSH к ноде оставляют аллокацию в состоянии `pending` с указанием причины, а кнопка **«Применить»** повторяет попытку позже. Нераспознанный сетевой стек даёт `runtime_only` — предупреждение, что адрес не переживёт перезагрузку
|
||||
- **Новые эндпоинты** — `GET/POST /ipam/api/guests/{server_id}/{vmid}/addresses`, `POST …/{allocation_id}/apply`, `POST …/{allocation_id}/primary`, `DELETE …/{allocation_id}`; список аллокаций принимает фильтры `proxmox_server_id` / `proxmox_vmid`, а список инстансов отдаёт `ips[]` рядом с основным `ip`
|
||||
- **Миграция 43** добавляет в `ipam_allocations` колонки `is_primary`, `assignment_kind`, `target_interface` и `apply_*`, помечая существующие записи основными. Первый адрес гостя теперь помечается автоматически, из какого бы из шести мест выдачи он ни пришёл
|
||||
- **Освобождение адреса сначала снимает его с гостя**, затем освобождает в IPAM; основной адрес задаётся конфигом интерфейса и из этой карточки не освобождается
|
||||
|
||||
### 🔍 Исправления определения адресов в IPAM
|
||||
|
||||
- **LXC-контейнеры наконец показывают свой IP** — `get_container_interfaces()` искал ключи `ipconfigN`, которые бывают только у QEMU-гостей с cloud-init. LXC хранит адрес внутри строки `netN`, поэтому метод не возвращал ничего и колонка IP у всех контейнеров оставалась пустой. Теперь используется парсер `netN`, а для гостей на DHCP — запрос `/nodes/{node}/lxc/{vmid}/interfaces`
|
||||
- **«Сканировать и связать» регистрирует все адреса гостя**, а не только первый, и больше не пропускает уже связанных гостей — сменившийся или добавленный адрес раньше оставался незамеченным навсегда
|
||||
- **MAC-адрес переносится в аллокацию** при связывании — берётся с того интерфейса, на котором найден адрес
|
||||
- **Дублирующийся адрес больше нельзя отобрать у живого гостя** — когда два гостя заявляют один IP (обычно из-за устаревшего `ip=` в конфиге остановленного контейнера), владелец сохраняется, а конфликт возвращается с объяснением вместо тихой перезаписи записи
|
||||
- **Аллокации больше не сопоставляются по имени ресурса** — из-за этого два одноимённых гостя на разных серверах показывали чужой адрес
|
||||
- **История IPAM больше не падает на значениях даты/времени** — раньше это обрывало повторное связывание ошибкой 500
|
||||
- **IP гостя перепроверяется не чаще раза в 5 минут**, а не при каждом синке кэша (каждые 10 секунд) — это убрало сотни бессмысленных обращений к Proxmox в минуту
|
||||
|
||||
### 🐛 Прочие исправления
|
||||
|
||||
- **Скрипты с не-ASCII текстом снова выполняются на ВМ** — Proxmox кодирует payload для guest agent в base64 силами Perl, а тот падает с `Wide character in subroutine entry`, как только в скрипте встречается не-ASCII (достаточно комментария на кириллице). Теперь payload передаётся байтами
|
||||
- **Поиск следующего свободного IP умеет сканировать всю подсеть**, когда в сети нет пула с auto-assign, пропуская адреса нод и известные адреса гостей — чтобы не выдать адрес, который уже занят в сети
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 🖥️ Консоль 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
|
||||
|
||||
### 💾 Перенос дисков LXC-контейнера на другое хранилище
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](LICENSE)
|
||||
[](compose.yml)
|
||||
[](backend/)
|
||||
@@ -34,6 +34,7 @@ PVEmanager is a self-hosted web panel that provides a unified interface for mana
|
||||
- **SDN support** — manage Software-Defined Networking zones, VNets, and subnets; edit zones and VNets; auto-create IPAM networks for subnets
|
||||
- **Node networking** — manage node-level network interfaces (bridges, bonds, VLANs); apply/revert pending config
|
||||
- **Networks page** — unified UI for SDN and node interface management
|
||||
- **Several IP addresses per instance** — a guest can hold a primary address plus extra ones, managed from its Network tab; extras are applied inside the guest as aliases (`pct exec` for LXC, guest agent for VMs) and survive a reboot
|
||||
|
||||
### App Store
|
||||
- **Self-hosted app catalog** — one-click install of self-hosted apps, each into its own unprivileged LXC running Docker Compose (1 app = 1 LXC); multi-source catalog — `runtipi/runtipi-appstore` (~265 apps) plus optional `getumbrel/umbrel-apps` (~380 apps), selectable via `APPSTORE_SOURCES`
|
||||
@@ -64,7 +65,7 @@ PVEmanager is a self-hosted web panel that provides a unified interface for mana
|
||||
|
||||
### Other
|
||||
- **Command palette** — `Ctrl+K` palette with search over a unified navigation registry for keyboard-driven jumps to any page
|
||||
- **IPAM** — IP Address Management with allocation history and orphan detection
|
||||
- **IPAM** — IP Address Management with allocation history, orphan detection and several addresses per instance
|
||||
- **Multilingual** — Russian and English interface; language preference is per-user account
|
||||
- **Encrypted credentials** — sensitive fields stored encrypted in the database
|
||||
- **Styled confirmation dialogs** — all destructive actions use a consistent themed modal instead of the native browser `confirm()`
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Современная веб-панель для управления серверами Proxmox, виртуальными машинами и контейнерами LXC**
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](LICENSE)
|
||||
[](compose.yml)
|
||||
[](backend/)
|
||||
@@ -34,6 +34,7 @@ PVEmanager — это самостоятельно размещаемая веб
|
||||
- **Поддержка SDN** — управляйте зонами Software-Defined Networking, VNet и подсетями; редактируйте зоны и VNet; автоматическое создание сетей IPAM для подсетей
|
||||
- **Сетевые интерфейсы узлов** — управляйте сетевыми интерфейсами на уровне узла (мосты, связки, VLAN); применяйте/откатывайте ожидающую конфигурацию
|
||||
- **Страница сетей** — унифицированный интерфейс для управления SDN и сетевыми интерфейсами узлов
|
||||
- **Несколько IP-адресов на инстанс** — у гостя может быть основной адрес и дополнительные; управление на вкладке «Сеть» инстанса, дополнительные навешиваются внутри гостя как alias (`pct exec` для LXC, guest agent для ВМ) и переживают перезагрузку
|
||||
|
||||
### App Store
|
||||
- **Каталог self-hosted приложений** — установка в один клик, каждое приложение — в собственном unprivileged LXC с Docker Compose (1 приложение = 1 LXC); мультиисточниковый каталог — `runtipi/runtipi-appstore` (~265 приложений) плюс опциональный `getumbrel/umbrel-apps` (~380 приложений), выбор через `APPSTORE_SOURCES`
|
||||
@@ -64,7 +65,7 @@ PVEmanager — это самостоятельно размещаемая веб
|
||||
|
||||
### Прочее
|
||||
- **Командная палитра** — палитра по `Ctrl+K` с поиском по единому реестру навигации для перехода на любую страницу с клавиатуры
|
||||
- **IPAM** — управление IP-адресами с историей выделения и обнаружением сиротских адресов
|
||||
- **IPAM** — управление IP-адресами с историей выделения, обнаружением сиротских адресов и несколькими адресами на инстанс
|
||||
- **Многоязычность** — интерфейс на русском и английском языках; язык задаётся отдельно для каждого аккаунта в профиле
|
||||
- **Зашифрованные учетные данные** — чувствительные поля хранятся зашифрованными в базе данных
|
||||
- **Стилизованные диалоги** — все подтверждения действий используют единое тематическое модальное окно вместо нативного `confirm()` браузера
|
||||
|
||||
62
WIKI.md
62
WIKI.md
@@ -223,9 +223,15 @@ This section summarizes key API endpoints. All require JWT authentication.
|
||||
- `GET /ipam/api/networks` — list networks
|
||||
- `POST /ipam/api/networks` — create network
|
||||
- `DELETE /ipam/api/networks/{id}` — delete network
|
||||
- `GET /ipam/api/allocations` — list IP allocations
|
||||
- `GET /ipam/api/allocations` — list IP allocations (filters: `network_id`, `pool_id`, `status`, `proxmox_server_id`, `proxmox_vmid`)
|
||||
- `POST /ipam/api/allocations` — create IP allocation
|
||||
- `DELETE /ipam/api/allocations/{id}` — release IP
|
||||
- `GET /ipam/api/guests/{server_id}/{vmid}/addresses` — all addresses of an instance
|
||||
- `POST /ipam/api/guests/{server_id}/{vmid}/addresses` — give the instance an extra address
|
||||
- `POST /ipam/api/guests/{server_id}/{vmid}/addresses/{id}/apply` — retry applying it to the guest
|
||||
- `POST /ipam/api/guests/{server_id}/{vmid}/addresses/{id}/primary` — mark it primary
|
||||
- `DELETE /ipam/api/guests/{server_id}/{vmid}/addresses/{id}` — remove from the guest and release
|
||||
- `POST /ipam/api/link-allocations` — scan guests and register their addresses
|
||||
|
||||
### Notifications
|
||||
|
||||
@@ -262,6 +268,7 @@ This section summarizes key API endpoints. All require JWT authentication.
|
||||
### Backups & 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
|
||||
- `PUT /api/backups/storages/{server_id}/{storage_id}` — update storage
|
||||
- `DELETE /api/backups/storages/{server_id}/{storage_id}` — delete storage
|
||||
@@ -379,6 +386,7 @@ This section summarizes key API endpoints. All require JWT authentication.
|
||||
- Dynamic server and node selectors in network creation forms
|
||||
- Automatic IP allocation
|
||||
- IP reservation and release
|
||||
- **Several addresses per instance** — a primary address plus extra ones applied inside the guest as aliases
|
||||
- Allocation history
|
||||
|
||||
### Notifications
|
||||
@@ -731,6 +739,7 @@ Either action that changes boot order applies it via a **hybrid reboot**: a grac
|
||||
The bundled amd64 image catalog points to a **GitHub mirror** that contains cloud images pre-patched with `qemu-guest-agent`. This means:
|
||||
|
||||
- Live per-VM metrics (CPU, memory, disk fill, I/O rates) work immediately after deploy — no post-install agent setup needed
|
||||
- RHEL-family images (Rocky Linux, AlmaLinux, CentOS Stream, Fedora) get their RPC restriction cleared: the stock `qemu-guest-agent` package there runs the agent with an allow-list that omits `guest-exec`, `guest-exec-status` and `guest-file-*` (`FILTER_RPC_ARGS` in `/etc/sysconfig/qemu-ga`), which broke script execution, extra IP addresses and filesystem growth after a disk resize in such VMs
|
||||
- Images include a **sha256 checksum** for integrity verification; if the cached file no longer matches the published checksum, the panel re-downloads it automatically
|
||||
|
||||
To rebuild or update the mirror yourself, run the included helper script on a Linux host with QEMU installed:
|
||||
@@ -739,7 +748,7 @@ To rebuild or update the mirror yourself, run the included helper script on a Li
|
||||
bash patch-cloud-images.sh
|
||||
```
|
||||
|
||||
It downloads the upstream `.qcow2`, installs `qemu-guest-agent` via `virt-customize`, and publishes the patched image as a GitHub Release.
|
||||
It downloads the upstream `.qcow2`, installs `qemu-guest-agent` via `virt-customize`, clears the RPC blocklist (turn it off with `UNBLOCK_RPC=0`), and publishes the patched image as a GitHub Release.
|
||||
|
||||
### Custom Mirrors (Admin Only)
|
||||
|
||||
@@ -1159,6 +1168,41 @@ Start: 192.168.1.10
|
||||
End: 192.168.1.50
|
||||
```
|
||||
|
||||
### Several addresses per instance
|
||||
|
||||
A guest holds one **primary** address plus any number of extra ones. The primary address is the one written into the guest's NIC configuration (`net0` for LXC, `ipconfig0` for QEMU) — it is what the IP column of the instance list shows. Extra addresses are added **inside** the guest as aliases on an existing interface.
|
||||
|
||||
Manage them on the **Network** tab of an instance, in the **IP addresses** card:
|
||||
|
||||
| Action | What happens |
|
||||
|--------|--------------|
|
||||
| Add IP | Allocates an address (next free in a network/pool, or one you type) and applies it to the chosen interface |
|
||||
| Apply | Retries applying an address that could not be pushed to the guest earlier |
|
||||
| Make primary | Moves the primary flag; the IP column and the instance cache follow it |
|
||||
| Release | Removes the address from the guest, then frees it in IPAM |
|
||||
|
||||
The primary address is not releasable from this card — it belongs to the NIC configuration and is changed through the network device instead.
|
||||
|
||||
### How an extra address is applied
|
||||
|
||||
The address is brought up with `ip addr add` and the guest's networking is **never restarted**, so a running guest keeps its connectivity. Delivery reuses the script engine:
|
||||
|
||||
- **LXC** — `pct exec` over SSH to the node (needs the server password or an SSH key for `root@node`)
|
||||
- **QEMU** — the guest agent, which must allow `guest-exec` (some images ship with it blocked in `/etc/sysconfig/qemu-ga` or `/etc/default/qemu-guest-agent`, see [Installing Guest Agent](#installing-guest-agent); the bundled catalog images already have it unblocked)
|
||||
|
||||
Proxmox regenerates the guest network configuration on **every start** of a guest, wiping anything written into the native stack. To make an address survive a reboot, PVEmanager installs a small oneshot systemd unit (`pvemanager-aliases.service`) that re-adds the recorded addresses after `network-online.target`; `/etc/systemd` is not touched by Proxmox. The native stack is configured as well when it is recognised — ifupdown, netplan, NetworkManager or sysconfig/network-scripts for RHEL-based guests.
|
||||
|
||||
### Address states
|
||||
|
||||
| State | Meaning |
|
||||
|-------|---------|
|
||||
| 🟢 applied | Up in the guest and recorded so it returns after a reboot |
|
||||
| 🟡 runtime_only | Up in the guest, but no known network stack and no systemd — will be lost on reboot |
|
||||
| 🟠 pending | Reserved in IPAM, not pushed to the guest yet (guest stopped, no guest agent, no SSH to the node) — press **Apply** later |
|
||||
| 🔴 failed | The script ran and returned an error; the reason is shown next to the address |
|
||||
|
||||
An address is always reserved in IPAM even when it cannot be applied, so it is never handed out to somebody else in the meantime.
|
||||
|
||||
### IP Status
|
||||
|
||||
| Status | Description |
|
||||
@@ -1184,12 +1228,21 @@ apt install qemu-guest-agent
|
||||
systemctl enable --now qemu-guest-agent
|
||||
```
|
||||
|
||||
**CentOS/RHEL/AlmaLinux:**
|
||||
**CentOS/RHEL/AlmaLinux/Rocky/Fedora:**
|
||||
```bash
|
||||
yum install qemu-guest-agent
|
||||
dnf install qemu-guest-agent
|
||||
systemctl enable --now qemu-guest-agent
|
||||
```
|
||||
|
||||
On these distributions the agent blocks `guest-exec` by default — the panel gets `The command guest-exec has been disabled for this instance`, and command execution, extra IP addresses and filesystem growth stop working. To unblock:
|
||||
|
||||
```bash
|
||||
sed -i -e 's/^FILTER_RPC_ARGS=.*/FILTER_RPC_ARGS=/' -e 's/^BLACKLIST_RPC=.*/BLACKLIST_RPC=/' -e 's/^BLOCK_RPCS=.*/BLOCK_RPCS=/' /etc/sysconfig/qemu-ga
|
||||
systemctl restart qemu-guest-agent
|
||||
```
|
||||
|
||||
The layout of `/etc/sysconfig/qemu-ga` changed across releases, so the command clears all three variants: `FILTER_RPC_ARGS` (current packages — an allow-list that simply omits `guest-exec`), `BLOCK_RPCS` (qemu-ga 8.1+) and `BLACKLIST_RPC` (before 8.1). An empty value means no restrictions at all, the same as on Debian/Ubuntu where no filter is configured; lines that do not exist are left alone.
|
||||
|
||||
**Windows:**
|
||||
Download [virtio-win drivers](https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/)
|
||||
|
||||
@@ -2173,6 +2226,7 @@ curl -H "Authorization: Bearer eyJ..." \
|
||||
| GET | `/logs/api/logs` | Get audit logs |
|
||||
| GET | `/logs/api/stats` | Get log statistics |
|
||||
| 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/restore` | Restore VM/LXC from backup |
|
||||
| GET | `/api/backups/list/{server_id}` | List backup files |
|
||||
|
||||
62
WIKI.ru.md
62
WIKI.ru.md
@@ -222,9 +222,15 @@ CORS_ORIGINS=*
|
||||
- `GET /ipam/api/networks` — список сетей
|
||||
- `POST /ipam/api/networks` — создать сеть
|
||||
- `DELETE /ipam/api/networks/{id}` — удалить сеть
|
||||
- `GET /ipam/api/allocations` — список выделенных IP
|
||||
- `GET /ipam/api/allocations` — список выделенных IP (фильтры: `network_id`, `pool_id`, `status`, `proxmox_server_id`, `proxmox_vmid`)
|
||||
- `POST /ipam/api/allocations` — создать выделение IP
|
||||
- `DELETE /ipam/api/allocations/{id}` — освободить IP
|
||||
- `GET /ipam/api/guests/{server_id}/{vmid}/addresses` — все адреса инстанса
|
||||
- `POST /ipam/api/guests/{server_id}/{vmid}/addresses` — выдать инстансу дополнительный адрес
|
||||
- `POST /ipam/api/guests/{server_id}/{vmid}/addresses/{id}/apply` — повторить применение к гостю
|
||||
- `POST /ipam/api/guests/{server_id}/{vmid}/addresses/{id}/primary` — сделать основным
|
||||
- `DELETE /ipam/api/guests/{server_id}/{vmid}/addresses/{id}` — снять с гостя и освободить
|
||||
- `POST /ipam/api/link-allocations` — просканировать гостей и зарегистрировать их адреса
|
||||
|
||||
### Уведомления
|
||||
|
||||
@@ -261,6 +267,7 @@ CORS_ORIGINS=*
|
||||
### Резервные копии и хранилища
|
||||
|
||||
- `GET /api/backups/storages/{server_id}` — список хранилищ
|
||||
- `GET /api/backups/scan-nfs/{server_id}?server=<ip>` — сканировать NFS-сервер на доступные экспорты
|
||||
- `POST /api/backups/storages/{server_id}` — добавить хранилище
|
||||
- `PUT /api/backups/storages/{server_id}/{storage_id}` — обновить хранилище
|
||||
- `DELETE /api/backups/storages/{server_id}/{storage_id}` — удалить хранилище
|
||||
@@ -378,6 +385,7 @@ CORS_ORIGINS=*
|
||||
- Динамические селекторы сервера и узла в формах создания сети
|
||||
- Автоматическое выделение IP
|
||||
- Резервирование и освобождение IP
|
||||
- **Несколько адресов на инстанс** — основной адрес и дополнительные, навешиваемые внутри гостя как alias
|
||||
- История выделений
|
||||
|
||||
### Уведомления
|
||||
@@ -730,6 +738,7 @@ RAM: 512 МБ – 128 ГБ
|
||||
Встроенный каталог образов amd64 ссылается на **GitHub-зеркало**, содержащее облачные образы с предустановленным `qemu-guest-agent`. Это даёт:
|
||||
|
||||
- Живые метрики на ВМ (CPU, память, заполнение диска, скорости I/O) сразу после деплоя — дополнительная установка агента не нужна
|
||||
- В образах RHEL-семейства (Rocky Linux, AlmaLinux, CentOS Stream, Fedora) снято штатное ограничение RPC: пакет `qemu-guest-agent` там оставляет агенту allow-list без `guest-exec`, `guest-exec-status` и `guest-file-*` (`FILTER_RPC_ARGS` в `/etc/sysconfig/qemu-ga`), из-за чего в таких ВМ не работали выполнение скриптов, дополнительные IP-адреса и расширение ФС после ресайза диска
|
||||
- Образы включают контрольную сумму **sha256**; если закешированный файл не совпадает с опубликованной суммой, панель скачает его повторно автоматически
|
||||
|
||||
Чтобы собрать или обновить зеркало самостоятельно, запустите вспомогательный скрипт на Linux-хосте с установленным QEMU:
|
||||
@@ -738,7 +747,7 @@ RAM: 512 МБ – 128 ГБ
|
||||
bash patch-cloud-images.sh
|
||||
```
|
||||
|
||||
Скрипт скачивает исходный `.qcow2`, устанавливает `qemu-guest-agent` через `virt-customize` и публикует патченный образ как GitHub Release.
|
||||
Скрипт скачивает исходный `.qcow2`, устанавливает `qemu-guest-agent` через `virt-customize`, снимает блокировку RPC (отключается через `UNBLOCK_RPC=0`) и публикует патченный образ как GitHub Release.
|
||||
|
||||
### Кастомные зеркала (только админ)
|
||||
|
||||
@@ -1158,6 +1167,41 @@ Start: 192.168.1.10
|
||||
End: 192.168.1.50
|
||||
```
|
||||
|
||||
### Несколько адресов на инстанс
|
||||
|
||||
У гостя есть один **основной** адрес и любое количество дополнительных. Основной — это адрес из конфигурации интерфейса гостя (`net0` у LXC, `ipconfig0` у QEMU), именно он показывается в колонке IP списка инстансов. Дополнительные адреса навешиваются **внутри** гостя как alias на существующий интерфейс.
|
||||
|
||||
Управление — на вкладке **«Сеть»** инстанса, карточка **«IP-адреса»**:
|
||||
|
||||
| Действие | Что происходит |
|
||||
|----------|----------------|
|
||||
| Добавить IP | Выделяет адрес (следующий свободный в сети/пуле или введённый вручную) и применяет его к выбранному интерфейсу |
|
||||
| Применить | Повторяет применение адреса, который раньше не удалось передать гостю |
|
||||
| Сделать основным | Переносит флаг основного адреса; колонка IP и кэш инстанса следуют за ним |
|
||||
| Освободить | Снимает адрес с гостя, затем освобождает его в IPAM |
|
||||
|
||||
Основной адрес из этой карточки не освобождается — он задаётся конфигурацией сетевого интерфейса и меняется через неё.
|
||||
|
||||
### Как применяется дополнительный адрес
|
||||
|
||||
Адрес поднимается командой `ip addr add`, сеть гостя при этом **не перезапускается**, поэтому работающий гость не теряет связь. Доставка использует тот же движок скриптов, что и раздел Scripts:
|
||||
|
||||
- **LXC** — `pct exec` по SSH к ноде (нужен пароль сервера либо SSH-ключ для `root@node`)
|
||||
- **QEMU** — guest agent, в котором должен быть разрешён `guest-exec` (в некоторых образах он заблокирован в `/etc/sysconfig/qemu-ga` или `/etc/default/qemu-guest-agent`, см. [Установка Guest Agent](#установка-guest-agent); в образах встроенного каталога блокировка уже снята)
|
||||
|
||||
Proxmox пересоздаёт сетевую конфигурацию гостя при **каждом старте**, стирая всё, что было записано в родной стек. Чтобы адрес пережил перезагрузку, PVEmanager устанавливает небольшой oneshot systemd-юнит (`pvemanager-aliases.service`), который заново поднимает записанные адреса после `network-online.target`; каталог `/etc/systemd` Proxmox не трогает. Родной стек настраивается дополнительно, если он распознан — ifupdown, netplan, NetworkManager или sysconfig/network-scripts для RHEL-подобных гостей.
|
||||
|
||||
### Состояния адреса
|
||||
|
||||
| Состояние | Значение |
|
||||
|-----------|----------|
|
||||
| 🟢 applied | Поднят в госте и записан так, что вернётся после перезагрузки |
|
||||
| 🟡 runtime_only | Поднят в госте, но сетевой стек не распознан и systemd нет — после перезагрузки исчезнет |
|
||||
| 🟠 pending | Зарезервирован в IPAM, но гостю ещё не передан (гость выключен, нет guest agent, нет SSH к ноде) — нажмите **«Применить»** позже |
|
||||
| 🔴 failed | Скрипт отработал с ошибкой; причина показана рядом с адресом |
|
||||
|
||||
Адрес резервируется в IPAM в любом случае, даже если применить его не удалось, — чтобы тем временем он не достался кому-то ещё.
|
||||
|
||||
### Статус IP
|
||||
|
||||
| Статус | Описание |
|
||||
@@ -1183,12 +1227,21 @@ apt install qemu-guest-agent
|
||||
systemctl enable --now qemu-guest-agent
|
||||
```
|
||||
|
||||
**CentOS/RHEL/AlmaLinux:**
|
||||
**CentOS/RHEL/AlmaLinux/Rocky/Fedora:**
|
||||
```bash
|
||||
yum install qemu-guest-agent
|
||||
dnf install qemu-guest-agent
|
||||
systemctl enable --now qemu-guest-agent
|
||||
```
|
||||
|
||||
В этих дистрибутивах агент по умолчанию запрещает `guest-exec` — панель получает `The command guest-exec has been disabled for this instance`, и выполнение команд, выдача дополнительных IP и расширение ФС не работают. Разблокировать:
|
||||
|
||||
```bash
|
||||
sed -i -e 's/^FILTER_RPC_ARGS=.*/FILTER_RPC_ARGS=/' -e 's/^BLACKLIST_RPC=.*/BLACKLIST_RPC=/' -e 's/^BLOCK_RPCS=.*/BLOCK_RPCS=/' /etc/sysconfig/qemu-ga
|
||||
systemctl restart qemu-guest-agent
|
||||
```
|
||||
|
||||
Разметка `/etc/sysconfig/qemu-ga` менялась от версии к версии, поэтому команда чистит все три варианта: `FILTER_RPC_ARGS` (актуальные пакеты — allow-list, в котором `guest-exec` просто отсутствует), `BLOCK_RPCS` (qemu-ga 8.1+) и `BLACKLIST_RPC` (до 8.1). Пустое значение = агент без ограничений, как в Debian/Ubuntu, где фильтра нет вовсе; отсутствующие строки sed просто не найдёт.
|
||||
|
||||
**Windows:**
|
||||
Скачайте [virtio-win drivers](https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/)
|
||||
|
||||
@@ -2086,6 +2139,7 @@ curl -H "Authorization: Bearer eyJ..." \
|
||||
| GET | `/logs/api/logs` | Получить логи аудита |
|
||||
| GET | `/logs/api/stats` | Статистика логов |
|
||||
| GET | `/api/backups/storages/{server_id}` | Список хранилищ Proxmox |
|
||||
| GET | `/api/backups/scan-nfs/{server_id}` | Сканировать NFS-сервер на экспорты |
|
||||
| POST | `/api/backups/create` | Создать копию VM/LXC |
|
||||
| POST | `/api/backups/restore` | Восстановить VM/LXC из копии |
|
||||
| GET | `/api/backups/list/{server_id}` | Список файлов копий |
|
||||
|
||||
@@ -5,7 +5,7 @@ IP Address Management endpoints for networks, pools, and allocations.
|
||||
|
||||
import logging
|
||||
import ipaddress as ipaddress_module
|
||||
from typing import List, Optional
|
||||
from typing import List, NamedTuple, Optional
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import or_
|
||||
@@ -21,6 +21,8 @@ from ..schemas import (
|
||||
IPAMHistoryResponse, IPAMAutoAllocateRequest
|
||||
)
|
||||
from ..ipam_service import IPAMService
|
||||
from ..services import guest_ip_service
|
||||
from .proxmox._helpers import require_vm_access
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -382,6 +384,8 @@ def get_allocations(
|
||||
pool_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
proxmox_server_id: Optional[int] = None,
|
||||
proxmox_vmid: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = Query(default=100, le=1000),
|
||||
offset: int = 0,
|
||||
@@ -402,6 +406,13 @@ def get_allocations(
|
||||
|
||||
if resource_type:
|
||||
query = query.filter(IPAMAllocation.resource_type == resource_type)
|
||||
|
||||
# Фильтр по конкретному гостю: у него может быть несколько адресов
|
||||
if proxmox_server_id is not None:
|
||||
query = query.filter(IPAMAllocation.proxmox_server_id == proxmox_server_id)
|
||||
|
||||
if proxmox_vmid is not None:
|
||||
query = query.filter(IPAMAllocation.proxmox_vmid == proxmox_vmid)
|
||||
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
@@ -789,13 +800,29 @@ async def cleanup_orphan_allocations(
|
||||
}
|
||||
|
||||
|
||||
def _extract_primary_ipv4(interfaces: list, parsed_networks: list):
|
||||
"""Extract first IPv4 that belongs to any IPAM network from interface list."""
|
||||
class DiscoveredAddress(NamedTuple):
|
||||
"""Адрес гостя, найденный в одной из известных IPAM-сетей."""
|
||||
ip: str
|
||||
network: object # IPAMNetwork
|
||||
mac: Optional[str] # MAC интерфейса, если он известен
|
||||
interface: Optional[str] # имя интерфейса гостя (eth0, ens18, ...)
|
||||
|
||||
|
||||
def _extract_ipv4s(interfaces: list, parsed_networks: list) -> List[DiscoveredAddress]:
|
||||
"""Все IPv4 гостя, попадающие в известные IPAM-сети, в порядке обнаружения.
|
||||
|
||||
Гость может держать несколько адресов, поэтому возвращается список, а не
|
||||
первое совпадение. Loopback и link-local отбрасываются.
|
||||
"""
|
||||
found: List[DiscoveredAddress] = []
|
||||
seen = set()
|
||||
for iface in interfaces:
|
||||
for ip_info in iface.get('ips', []):
|
||||
if ip_info.get('type') != 'ipv4':
|
||||
continue
|
||||
addr = ip_info.get('address', '')
|
||||
if not addr or addr in seen:
|
||||
continue
|
||||
try:
|
||||
ip_obj = ipaddress_module.ip_address(addr)
|
||||
except ValueError:
|
||||
@@ -804,8 +831,15 @@ def _extract_primary_ipv4(interfaces: list, parsed_networks: list):
|
||||
continue
|
||||
for net, net_obj in parsed_networks:
|
||||
if ip_obj in net_obj:
|
||||
return addr, net
|
||||
return None, None
|
||||
seen.add(addr)
|
||||
found.append(DiscoveredAddress(
|
||||
ip=addr,
|
||||
network=net,
|
||||
mac=(iface.get('hardware_address') or '').upper() or None,
|
||||
interface=iface.get('name') or None,
|
||||
))
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
@router.get("/api/unlinked")
|
||||
@@ -814,21 +848,26 @@ def get_unlinked_allocations(
|
||||
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
||||
):
|
||||
"""
|
||||
Return VM instances that have no IPAM allocation linked to them (by vmid+server_id).
|
||||
Only VMs whose cached ip_address falls within an active IPAM network are included.
|
||||
Return VM instances whose current address is not registered in IPAM.
|
||||
|
||||
У гостя может быть несколько адресов, поэтому «связан» определяется не по
|
||||
наличию хоть какой-то аллокации, а по конкретному адресу: если в кэше
|
||||
инстанса стоит IP, которого нет среди его аллокаций, гость показывается
|
||||
как несвязанный. Учитываются только адреса из активных IPAM-сетей.
|
||||
"""
|
||||
unlinked = []
|
||||
|
||||
# All live VMs
|
||||
vms = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None)).all()
|
||||
|
||||
# Pre-load all linked (proxmox_server_id, proxmox_vmid) pairs for fast lookup
|
||||
linked_pairs = set(
|
||||
(a.proxmox_server_id, a.proxmox_vmid)
|
||||
for a in db.query(IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid).filter(
|
||||
IPAMAllocation.proxmox_vmid.isnot(None)
|
||||
).all()
|
||||
)
|
||||
# (server_id, vmid) -> множество уже учтённых адресов гостя
|
||||
linked_ips = {}
|
||||
for alloc in db.query(
|
||||
IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid, IPAMAllocation.ip_address
|
||||
).filter(IPAMAllocation.proxmox_vmid.isnot(None)).all():
|
||||
linked_ips.setdefault((alloc.proxmox_server_id, alloc.proxmox_vmid), set()).add(
|
||||
alloc.ip_address
|
||||
)
|
||||
|
||||
servers_map = {s.id: s for s in db.query(ProxmoxServer).all()}
|
||||
|
||||
@@ -841,12 +880,11 @@ def get_unlinked_allocations(
|
||||
pass
|
||||
|
||||
for vm in vms:
|
||||
if (vm.server_id, vm.vmid) in linked_pairs:
|
||||
continue
|
||||
|
||||
# Skip VMs with no IP or an IP outside all IPAM networks
|
||||
if not vm.ip_address:
|
||||
continue
|
||||
if vm.ip_address in linked_ips.get((vm.server_id, vm.vmid), ()):
|
||||
continue
|
||||
try:
|
||||
vm_ip = ipaddress_module.ip_address(vm.ip_address)
|
||||
if not any(vm_ip in net for net in ipam_nets):
|
||||
@@ -972,9 +1010,9 @@ def link_allocations_to_vms(
|
||||
continue
|
||||
|
||||
for vm in server_vms:
|
||||
# Skip already linked
|
||||
if (server_id, vm.vmid) in linked_pairs:
|
||||
continue
|
||||
# Уже связанных больше не пропускаем: у гостя может появиться
|
||||
# второй адрес или смениться существующий — и то, и другое раньше
|
||||
# оставалось незамеченным навсегда.
|
||||
|
||||
# For this VM, further filter networks by proxmox_node if set
|
||||
# (e.g. network tagged to pve1 should not match VMs on pve2)
|
||||
@@ -997,40 +1035,59 @@ def link_allocations_to_vms(
|
||||
logger.debug(f"[IPAM link] Cannot get interfaces for {vm.name}/{vm.vmid}: {e}")
|
||||
interfaces = []
|
||||
|
||||
# Find first IPv4 in one of this VM's applicable IPAM networks
|
||||
ip_found, matching_network = _extract_primary_ipv4(interfaces, vm_networks)
|
||||
# Все IPv4 гостя, попадающие в его IPAM-сети
|
||||
discovered = _extract_ipv4s(interfaces, vm_networks)
|
||||
|
||||
if not ip_found:
|
||||
if not discovered:
|
||||
not_found.append({'ip_address': '-', 'resource_name': vm.name,
|
||||
'reason': 'no_ip_in_ipam_network'})
|
||||
continue
|
||||
|
||||
alloc, error = service.sync_from_proxmox_vm(
|
||||
network_id=matching_network.id,
|
||||
proxmox_server_id=server_id,
|
||||
vmid=vm.vmid,
|
||||
vm_name=vm.name,
|
||||
vm_type=vm.vm_type,
|
||||
ip_address=ip_found,
|
||||
node=vm.node,
|
||||
synced_by=synced_by
|
||||
)
|
||||
already_linked = {
|
||||
a.ip_address for a in
|
||||
service.find_allocations_by_resource(server_id, vm.vmid)
|
||||
}
|
||||
|
||||
if alloc:
|
||||
# Update the cached ip_address in vm_instances as well
|
||||
vm.ip_address = ip_found
|
||||
linked_pairs.add((server_id, vm.vmid)) # avoid double-linking
|
||||
linked.append({
|
||||
'ip_address': ip_found,
|
||||
'resource_name': vm.name,
|
||||
'vmid': vm.vmid,
|
||||
'server_id': server_id
|
||||
})
|
||||
logger.info(f"[IPAM link] Linked {ip_found} -> VM {vm.vmid} ({vm.name}) on {server.name}")
|
||||
else:
|
||||
not_found.append({'ip_address': ip_found, 'resource_name': vm.name,
|
||||
'reason': error or 'sync_error'})
|
||||
logger.warning(f"[IPAM link] Failed {ip_found} for {vm.name}: {error}")
|
||||
for found in discovered:
|
||||
if found.ip in already_linked:
|
||||
continue # этот адрес гостя уже учтён
|
||||
|
||||
alloc, error = service.sync_from_proxmox_vm(
|
||||
network_id=found.network.id,
|
||||
proxmox_server_id=server_id,
|
||||
vmid=vm.vmid,
|
||||
vm_name=vm.name,
|
||||
vm_type=vm.vm_type,
|
||||
ip_address=found.ip,
|
||||
node=vm.node,
|
||||
mac_address=found.mac,
|
||||
synced_by=synced_by,
|
||||
target_interface=found.interface,
|
||||
# Адреса сверх первого — алиасы: их поднимают внутри
|
||||
# гостя, а не через net0/ipconfig0.
|
||||
assignment_kind='alias',
|
||||
)
|
||||
|
||||
if alloc:
|
||||
already_linked.add(found.ip)
|
||||
linked_pairs.add((server_id, vm.vmid))
|
||||
# Кэш инстанса показывает основной адрес
|
||||
if alloc.is_primary:
|
||||
vm.ip_address = found.ip
|
||||
linked.append({
|
||||
'ip_address': found.ip,
|
||||
'resource_name': vm.name,
|
||||
'vmid': vm.vmid,
|
||||
'server_id': server_id,
|
||||
'is_primary': bool(alloc.is_primary),
|
||||
})
|
||||
logger.info(
|
||||
f"[IPAM link] Linked {found.ip} -> VM {vm.vmid} ({vm.name}) on {server.name}"
|
||||
)
|
||||
else:
|
||||
not_found.append({'ip_address': found.ip, 'resource_name': vm.name,
|
||||
'reason': error or 'sync_error'})
|
||||
logger.warning(f"[IPAM link] Failed {found.ip} for {vm.name}: {error}")
|
||||
|
||||
if linked:
|
||||
try:
|
||||
@@ -1047,3 +1104,261 @@ def link_allocations_to_vms(
|
||||
'not_found_count': len(not_found)
|
||||
}
|
||||
|
||||
|
||||
# ==================== Guest addresses (несколько IP на инстанс) ====================
|
||||
#
|
||||
# Основной адрес гостя задаётся конфигом Proxmox (net0 / ipconfig0) и здесь
|
||||
# только помечается флагом. Дополнительные навешиваются алиасами внутри гостя
|
||||
# силами services/guest_ip_service.py.
|
||||
|
||||
|
||||
class GuestAddressCreate(BaseModel):
|
||||
network_id: int
|
||||
pool_id: Optional[int] = None
|
||||
ip_address: Optional[str] = None # пусто -> следующий свободный в сети/пуле
|
||||
target_interface: Optional[str] = None # пусто -> интерфейс основного адреса
|
||||
make_primary: bool = False
|
||||
hostname: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
def _allocation_payload(db: Session, alloc: IPAMAllocation) -> dict:
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == alloc.network_id).first()
|
||||
prefix = None
|
||||
if network and network.network and '/' in network.network:
|
||||
prefix = network.network.split('/')[1]
|
||||
return {
|
||||
'id': alloc.id,
|
||||
'ip_address': alloc.ip_address,
|
||||
'prefix': prefix,
|
||||
'network_id': alloc.network_id,
|
||||
'network_name': network.name if network else None,
|
||||
'gateway': network.gateway if network else None,
|
||||
'mac_address': alloc.mac_address,
|
||||
'status': alloc.status,
|
||||
'is_primary': bool(alloc.is_primary),
|
||||
'assignment_kind': alloc.assignment_kind,
|
||||
'target_interface': alloc.target_interface,
|
||||
'apply_status': alloc.apply_status,
|
||||
'apply_error': alloc.apply_error,
|
||||
'applied_at': alloc.applied_at,
|
||||
'hostname': alloc.hostname,
|
||||
'notes': alloc.notes,
|
||||
}
|
||||
|
||||
|
||||
def _guest_or_404(db: Session, server_id: int, vmid: int) -> VMInstance:
|
||||
guest = db.query(VMInstance).filter(
|
||||
VMInstance.server_id == server_id,
|
||||
VMInstance.vmid == vmid,
|
||||
VMInstance.deleted_at.is_(None)
|
||||
).first()
|
||||
if not guest:
|
||||
raise HTTPException(status_code=404, detail="Instance not found")
|
||||
return guest
|
||||
|
||||
|
||||
def _guest_allocation_or_404(db: Session, server_id: int, vmid: int,
|
||||
allocation_id: int) -> IPAMAllocation:
|
||||
allocation = db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.id == allocation_id,
|
||||
IPAMAllocation.proxmox_server_id == server_id,
|
||||
IPAMAllocation.proxmox_vmid == vmid
|
||||
).first()
|
||||
if not allocation:
|
||||
raise HTTPException(status_code=404, detail="Address not found for this instance")
|
||||
return allocation
|
||||
|
||||
|
||||
def _refresh_primary_cache(db: Session, service: IPAMService, guest: VMInstance) -> None:
|
||||
"""Кэш инстанса показывает основной адрес — он же в колонке IP списка."""
|
||||
primary = service.find_allocation_by_resource(guest.server_id, guest.vmid)
|
||||
if primary:
|
||||
guest.ip_address = primary.ip_address
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/api/guests/{server_id}/{vmid}/addresses")
|
||||
def list_guest_addresses(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam:view"))
|
||||
):
|
||||
"""Все IP-адреса инстанса: основной первым, затем алиасы."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
service = IPAMService(db)
|
||||
rows = service.find_allocations_by_resource(server_id, vmid)
|
||||
return {
|
||||
'addresses': [_allocation_payload(db, a) for a in rows],
|
||||
'count': len(rows),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/guests/{server_id}/{vmid}/addresses")
|
||||
def add_guest_address(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
body: GuestAddressCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
||||
):
|
||||
"""Выдать инстансу дополнительный адрес и навесить его на интерфейс.
|
||||
|
||||
Адрес резервируется в IPAM в любом случае: если гость недоступен, запись
|
||||
остаётся со статусом pending, и её можно применить позже.
|
||||
"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
guest = _guest_or_404(db, server_id, vmid)
|
||||
service = IPAMService(db)
|
||||
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == body.network_id).first()
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="IPAM network not found")
|
||||
|
||||
ip_address = body.ip_address
|
||||
if not ip_address:
|
||||
# Доп. адрес выдаётся точечно, поэтому при отсутствии auto-assign пула
|
||||
# ищем свободный по всей подсети, а не отказываем.
|
||||
ip_address = service.get_next_available_ip(
|
||||
body.network_id, body.pool_id, allow_network_scan=True
|
||||
)
|
||||
if not ip_address:
|
||||
raise HTTPException(status_code=409, detail="No free IP left in this network/pool")
|
||||
|
||||
interface = body.target_interface
|
||||
if not interface:
|
||||
primary = service.find_allocation_by_resource(server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
try:
|
||||
client = ProxmoxClient.from_server(server)
|
||||
interface = guest_ip_service.resolve_interface(
|
||||
client, guest.vm_type, guest.node, vmid,
|
||||
primary_ip=primary.ip_address if primary else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[guest-ip] fallback interface for {vmid}: {e}")
|
||||
interface = guest_ip_service.DEFAULT_INTERFACE
|
||||
|
||||
allocation, error = service.allocate_ip(
|
||||
ip_address=ip_address,
|
||||
network_id=body.network_id,
|
||||
pool_id=body.pool_id,
|
||||
resource_type=guest.vm_type,
|
||||
resource_name=guest.name,
|
||||
hostname=body.hostname,
|
||||
proxmox_server_id=server_id,
|
||||
proxmox_vmid=vmid,
|
||||
proxmox_node=guest.node,
|
||||
allocated_by=current_user.username,
|
||||
notes=body.notes,
|
||||
assignment_kind="alias",
|
||||
target_interface=interface,
|
||||
apply_status=guest_ip_service.PENDING,
|
||||
)
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
apply_result = guest_ip_service.apply_address(db, allocation)
|
||||
|
||||
if body.make_primary:
|
||||
service.set_primary(server_id, vmid, allocation.id, changed_by=current_user.username)
|
||||
|
||||
_refresh_primary_cache(db, service, guest)
|
||||
db.refresh(allocation)
|
||||
|
||||
return {
|
||||
'address': _allocation_payload(db, allocation),
|
||||
'applied': apply_result.success,
|
||||
'apply_status': apply_result.status,
|
||||
'persist': apply_result.persist,
|
||||
'error': apply_result.error,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}/apply")
|
||||
def apply_guest_address(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
allocation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
||||
):
|
||||
"""Повторить применение адреса — например, после включения гостя."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
allocation = _guest_allocation_or_404(db, server_id, vmid, allocation_id)
|
||||
|
||||
result = guest_ip_service.apply_address(db, allocation)
|
||||
db.refresh(allocation)
|
||||
return {
|
||||
'address': _allocation_payload(db, allocation),
|
||||
'applied': result.success,
|
||||
'apply_status': result.status,
|
||||
'persist': result.persist,
|
||||
'error': result.error,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}/primary")
|
||||
def set_guest_primary_address(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
allocation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
||||
):
|
||||
"""Пометить адрес основным — он показывается в колонке IP списка инстансов."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
guest = _guest_or_404(db, server_id, vmid)
|
||||
_guest_allocation_or_404(db, server_id, vmid, allocation_id)
|
||||
|
||||
service = IPAMService(db)
|
||||
allocation, error = service.set_primary(
|
||||
server_id, vmid, allocation_id, changed_by=current_user.username
|
||||
)
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
_refresh_primary_cache(db, service, guest)
|
||||
return {'address': _allocation_payload(db, allocation)}
|
||||
|
||||
|
||||
@router.delete("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}")
|
||||
def remove_guest_address(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
allocation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
||||
):
|
||||
"""Снять адрес с гостя и освободить его в IPAM."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
guest = _guest_or_404(db, server_id, vmid)
|
||||
allocation = _guest_allocation_or_404(db, server_id, vmid, allocation_id)
|
||||
|
||||
if allocation.assignment_kind != "alias":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Primary address comes from the NIC config — change it on the Network tab",
|
||||
)
|
||||
|
||||
ip_address = allocation.ip_address
|
||||
interface = allocation.target_interface or guest_ip_service.DEFAULT_INTERFACE
|
||||
|
||||
service = IPAMService(db)
|
||||
released, error = service.release_ip(
|
||||
ip_address, released_by=current_user.username,
|
||||
reason=f"Removed from {guest.name} ({vmid})",
|
||||
)
|
||||
if not released:
|
||||
raise HTTPException(status_code=400, detail=error or "Failed to release IP")
|
||||
|
||||
# Аллокации уже нет — синхронизация снимет адрес с интерфейса гостя.
|
||||
result = guest_ip_service.sync_interface(db, server_id, vmid, interface)
|
||||
|
||||
_refresh_primary_cache(db, service, guest)
|
||||
return {
|
||||
'released': ip_address,
|
||||
'removed_from_guest': result.success,
|
||||
'error': result.error,
|
||||
}
|
||||
|
||||
@@ -64,15 +64,19 @@ def _do_reinstall_sync(task_id: int, server_id: int, vmid: int, node: str,
|
||||
# templates and LXC clones from a template VMID.
|
||||
# 2) ostemplate-file (LXC only, cached.template_name like "local:vztmpl/...tar.zst")
|
||||
tpl = None
|
||||
ostemplate_file = None
|
||||
ostemplate_file = None # LXC: CT template archive, e.g. "local:vztmpl/debian-13-...tar.zst"
|
||||
iso_volid = None # QEMU: install ISO, e.g. "local:iso/ubuntu-24.04.iso"
|
||||
if cached.template_id:
|
||||
tpl = db.query(OSTemplate).filter(OSTemplate.id == cached.template_id).first()
|
||||
if not tpl or not tpl.vmid:
|
||||
_update_deploy_task(task_id, 'failed', 'Шаблон не найден', 0,
|
||||
error='Шаблон не найден или некорректен')
|
||||
return
|
||||
elif is_lxc and cached.template_name and ':' in cached.template_name:
|
||||
ostemplate_file = cached.template_name
|
||||
elif cached.template_name and ':' in cached.template_name:
|
||||
if is_lxc:
|
||||
ostemplate_file = cached.template_name
|
||||
else:
|
||||
iso_volid = cached.template_name
|
||||
else:
|
||||
_update_deploy_task(task_id, 'failed', 'Шаблон не привязан', 0,
|
||||
error='У инстанса не сохранён шаблон, переустановка недоступна')
|
||||
@@ -94,7 +98,11 @@ def _do_reinstall_sync(task_id: int, server_id: int, vmid: int, node: str,
|
||||
cores = cached.cores
|
||||
memory = cached.memory
|
||||
description = cached.description
|
||||
memory_mb = int(memory) // (1024 * 1024) if memory else None
|
||||
# VMInstance.memory уже хранится в МБ (см. _bytes_to_mb в monitoring_worker) —
|
||||
# раньше здесь их ошибочно ещё раз делили на 1024*1024, получая 0, из-за чего
|
||||
# шаг "Re-apply config" ниже молча пропускал memory и VM оставалась с
|
||||
# памятью шаблона вместо своей прежней.
|
||||
memory_mb = int(memory) if memory else None
|
||||
|
||||
_update_deploy_task(task_id, 'running', 'Подключение к Proxmox...', 10, vmid=vmid, node=node)
|
||||
client = _connect(server)
|
||||
@@ -247,6 +255,90 @@ def _do_reinstall_sync(task_id: int, server_id: int, vmid: int, node: str,
|
||||
client.wait_for_task(node, upid, timeout=600)
|
||||
except Exception:
|
||||
pass
|
||||
elif iso_volid:
|
||||
# ---- QEMU: re-create blank VM for ISO install with same VMID ----
|
||||
_update_deploy_task(task_id, 'running',
|
||||
f'Создание ВМ для установки с {iso_volid.split("/")[-1]}...',
|
||||
50, vmid=vmid, node=node)
|
||||
|
||||
# Extract storage/disk size/bus from the previous main boot disk
|
||||
# (e.g. "local-lvm:vm-118-disk-0,size=32G")
|
||||
storage = 'local-lvm'
|
||||
disk_size_gb = 32
|
||||
disk_bus = 'scsi'
|
||||
try:
|
||||
for key in ('scsi0', 'virtio0', 'sata0'):
|
||||
disk_val = prev_cfg.get(key)
|
||||
if not disk_val or 'media=cdrom' in disk_val or 'cloudinit' in disk_val:
|
||||
continue
|
||||
storage = disk_val.split(':', 1)[0]
|
||||
disk_bus = re.match(r'([a-z]+)', key).group(1)
|
||||
if 'size=' in disk_val:
|
||||
sz = disk_val.split('size=')[1].split(',')[0].strip()
|
||||
if sz.endswith('G'):
|
||||
disk_size_gb = int(float(sz[:-1]))
|
||||
elif sz.endswith('T'):
|
||||
disk_size_gb = int(float(sz[:-1])) * 1024
|
||||
elif sz.endswith('M'):
|
||||
disk_size_gb = max(1, int(sz[:-1]) // 1024)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
net0_val = prev_cfg.get('net0', '') or ''
|
||||
net_model = 'virtio'
|
||||
bridge = 'vmbr0'
|
||||
try:
|
||||
if net0_val:
|
||||
net_model = net0_val.split(',', 1)[0].split('=')[0]
|
||||
m = re.search(r'bridge=([^,]+)', net0_val)
|
||||
if m:
|
||||
bridge = m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bios = 'ovmf' if prev_cfg.get('bios') == 'ovmf' else 'seabios'
|
||||
tpm_enabled = any(k.startswith('tpmstate') for k in prev_cfg)
|
||||
ostype = prev_cfg.get('ostype', 'l26')
|
||||
try:
|
||||
onboot = bool(int(prev_cfg.get('onboot', 0)))
|
||||
except Exception:
|
||||
onboot = False
|
||||
|
||||
try:
|
||||
upid = client.create_vm_from_iso(
|
||||
node=node,
|
||||
vmid=vmid,
|
||||
name=name,
|
||||
memory=memory_mb or 2048,
|
||||
cores=cores or 2,
|
||||
disk_storage=storage,
|
||||
disk_size=disk_size_gb,
|
||||
iso_volid=iso_volid,
|
||||
bridge=bridge,
|
||||
ostype=ostype,
|
||||
disk_bus=disk_bus,
|
||||
net_model=net_model,
|
||||
bios=bios,
|
||||
tpm=tpm_enabled,
|
||||
onboot=onboot,
|
||||
description=description or f'Reinstalled from {iso_volid}',
|
||||
)
|
||||
except Exception as e:
|
||||
_update_deploy_task(task_id, 'failed', 'Ошибка создания ВМ', 50,
|
||||
error=f'Create failed: {e}')
|
||||
return
|
||||
if not upid:
|
||||
_update_deploy_task(task_id, 'failed', 'Ошибка создания ВМ', 50,
|
||||
error='Proxmox не вернул UPID задачи создания ВМ')
|
||||
return
|
||||
|
||||
_update_deploy_task(task_id, 'running', 'Ожидание завершения создания...', 75,
|
||||
vmid=vmid, node=node)
|
||||
try:
|
||||
client.wait_for_task(node, upid, timeout=300)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# ---- Clone-from-template (VM or LXC with template_id) ----
|
||||
_update_deploy_task(task_id, 'running', f'Клонирование из шаблона {tpl.name}...', 50, vmid=vmid, node=node)
|
||||
@@ -294,11 +386,13 @@ def _do_reinstall_sync(task_id: int, server_id: int, vmid: int, node: str,
|
||||
logger.warning(f"[REINSTALL #{task_id}] re-apply config failed: {_ce}")
|
||||
|
||||
# 5) Restore disk size
|
||||
disk_size_bytes = cached.disk_size
|
||||
if disk_size_bytes and disk_size_bytes > 0:
|
||||
# VMInstance.disk_size уже хранится в ГБ (см. _bytes_to_gb в monitoring_worker) —
|
||||
# раньше здесь их ошибочно делили ещё раз на 1024**3, получая 0, из-за чего
|
||||
# ресайз ниже всегда пропускался и диск оставался того размера, что задал шаблон.
|
||||
disk_size_gb = cached.disk_size
|
||||
if disk_size_gb and disk_size_gb > 0:
|
||||
_update_deploy_task(task_id, 'running', 'Восстановление размера диска...', 92, vmid=vmid, node=node)
|
||||
try:
|
||||
disk_size_gb = disk_size_bytes // (1024 ** 3)
|
||||
if is_lxc:
|
||||
lxc_cfg = client.get_container_config(node, vmid)
|
||||
current_size_gb = 0
|
||||
@@ -363,6 +457,7 @@ def _do_reinstall_sync(task_id: int, server_id: int, vmid: int, node: str,
|
||||
'template_id': tpl.id if tpl else None,
|
||||
'template_vmid': template_vmid,
|
||||
'ostemplate': ostemplate_file,
|
||||
'iso_volid': iso_volid,
|
||||
}, success=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +53,35 @@ def get_storages(
|
||||
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}")
|
||||
async def create_storage(
|
||||
server_id: int,
|
||||
|
||||
@@ -164,20 +164,26 @@ def get_all_virtual_machines(
|
||||
ipam_networks = db.query(IPAMNetwork).all()
|
||||
network_map = {n.id: n for n in ipam_networks}
|
||||
|
||||
# Build IPAM lookups
|
||||
# Build IPAM lookups.
|
||||
# Только по идентификатору гостя: сопоставление по имени давало чужой адрес
|
||||
# одноимённым гостям на разных серверах (два разных "wireguard").
|
||||
# У гостя может быть несколько адресов: в ipam_by_vmid кладём основной
|
||||
# (он идёт в колонку IP), в ipam_all_by_vmid — весь список для поля ips.
|
||||
ipam_by_vmid = {}
|
||||
ipam_by_name = {}
|
||||
|
||||
ipam_all_by_vmid = {}
|
||||
|
||||
for alloc in ipam_allocations:
|
||||
if alloc.proxmox_server_id and alloc.proxmox_vmid:
|
||||
ipam_by_vmid[(alloc.proxmox_server_id, alloc.proxmox_vmid)] = alloc
|
||||
key = (alloc.proxmox_server_id, alloc.proxmox_vmid)
|
||||
ipam_all_by_vmid.setdefault(key, []).append(alloc)
|
||||
current = ipam_by_vmid.get(key)
|
||||
# Основной — тот, у кого стоит флаг; иначе самая ранняя запись
|
||||
# (аллокации старше миграции multi-IP флага не имеют).
|
||||
if current is None or (alloc.is_primary and not current.is_primary):
|
||||
ipam_by_vmid[key] = alloc
|
||||
elif alloc.resource_id:
|
||||
ipam_by_vmid[(None, alloc.resource_id)] = alloc
|
||||
if alloc.hostname:
|
||||
ipam_by_name[alloc.hostname.lower()] = alloc
|
||||
if alloc.resource_name:
|
||||
ipam_by_name[alloc.resource_name.lower()] = alloc
|
||||
|
||||
ipam_by_vmid.setdefault((None, alloc.resource_id), alloc)
|
||||
|
||||
# Build base query for cached VMs (not deleted, not templates)
|
||||
# joinedload(owner) avoids an N+1 query when rendering the owner column.
|
||||
query = db.query(VMInstance).options(joinedload(VMInstance.owner)).filter(
|
||||
@@ -267,14 +273,29 @@ def get_all_virtual_machines(
|
||||
|
||||
# Get IP from IPAM or cache
|
||||
ipam_alloc = (
|
||||
ipam_by_vmid.get((vm.server_id, vm.vmid)) or
|
||||
ipam_by_vmid.get((None, vm.vmid)) or
|
||||
ipam_by_name.get(vm.name.lower())
|
||||
ipam_by_vmid.get((vm.server_id, vm.vmid)) or
|
||||
ipam_by_vmid.get((None, vm.vmid))
|
||||
)
|
||||
|
||||
ip_address = ipam_alloc.ip_address if ipam_alloc else (vm.ip_address or "")
|
||||
ip_hostname = ipam_alloc.hostname if ipam_alloc else ""
|
||||
owner = ipam_alloc.allocated_by if ipam_alloc else ""
|
||||
|
||||
# Полный список адресов гостя: основной первым, дальше алиасы
|
||||
guest_allocs = sorted(
|
||||
ipam_all_by_vmid.get((vm.server_id, vm.vmid), []),
|
||||
key=lambda a: (not bool(a.is_primary), a.id),
|
||||
)
|
||||
ip_list = [
|
||||
{
|
||||
"ip": a.ip_address,
|
||||
"is_primary": bool(a.is_primary),
|
||||
"assignment_kind": a.assignment_kind,
|
||||
"target_interface": a.target_interface,
|
||||
"apply_status": a.apply_status,
|
||||
}
|
||||
for a in guest_allocs
|
||||
]
|
||||
|
||||
# Get network name from IPAM
|
||||
ip_network_name = ""
|
||||
@@ -312,8 +333,11 @@ def get_all_virtual_machines(
|
||||
"cores": vm.cores or 0,
|
||||
"memory": vm.memory or 0,
|
||||
"disk": live.get('disk') or 0,
|
||||
# ip/ip_address — основной адрес (обратная совместимость),
|
||||
# ips — все адреса гостя
|
||||
"ip": ip_address,
|
||||
"ip_address": ip_address,
|
||||
"ips": ip_list,
|
||||
"ip_hostname": ip_network_name,
|
||||
"os": os_template,
|
||||
"os_template": os_template,
|
||||
@@ -739,8 +763,9 @@ def reinstall_vm_endpoint(
|
||||
tpl = db.query(OSTemplate).filter(OSTemplate.id == cached.template_id).first()
|
||||
if not tpl or not tpl.vmid:
|
||||
raise HTTPException(status_code=400, detail="Template not found or invalid")
|
||||
elif is_lxc and cached.template_name and ':' in cached.template_name:
|
||||
elif cached.template_name and ':' in cached.template_name:
|
||||
# LXC created from CT template file (e.g. local:vztmpl/debian-13-...tar.zst)
|
||||
# or QEMU VM created from an install ISO (e.g. local:iso/ubuntu-24.04.iso)
|
||||
tpl = None
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="VM has no associated template; reinstall is not available")
|
||||
@@ -917,7 +942,18 @@ async def delete_vm(
|
||||
if isinstance(disk_info, dict):
|
||||
size_str = disk_info.get('size', '0G')
|
||||
disk_size = int(size_str.replace('G', '')) if isinstance(size_str, str) else None
|
||||
|
||||
|
||||
# save_vm_instance() безусловно перезаписывает template_id/template_name
|
||||
# у уже существующей записи; этот вызов их не знает (Proxmox config их не
|
||||
# содержит), поэтому явно переносим текущие значения — иначе привязка к
|
||||
# шаблону теряется навсегда, если удаление после этого не состоится
|
||||
# (VM осталась, а "переустановить из шаблона" сломалась).
|
||||
prev_instance = db.query(VMInstance).filter(
|
||||
VMInstance.server_id == server_id,
|
||||
VMInstance.vmid == vmid,
|
||||
VMInstance.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
save_vm_instance(
|
||||
db=db,
|
||||
server_id=server_id,
|
||||
@@ -930,7 +966,9 @@ async def delete_vm(
|
||||
disk_size=disk_size,
|
||||
ip_address=ip_address,
|
||||
ip_prefix=ip_prefix,
|
||||
description=config.get('description')
|
||||
description=config.get('description'),
|
||||
template_id=prev_instance.template_id if prev_instance else None,
|
||||
template_name=prev_instance.template_name if prev_instance else None,
|
||||
)
|
||||
logger.info(f"Saved VM {vmid} configuration before deletion")
|
||||
except Exception as e:
|
||||
@@ -2884,79 +2922,6 @@ def get_vm_terminal(
|
||||
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")
|
||||
async def exec_in_container(
|
||||
server_id: int,
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
Встроенный curated-каталог cloud-образов ВМ (qcow2).
|
||||
|
||||
amd64-образы берутся с собственного GitHub-зеркала (markmorado/pvemanager-images)
|
||||
с предустановленным qemu-guest-agent и фиксированным sha256. arm64-образы идут
|
||||
напрямую с upstream-серверов дистрибутивов (патченых arm64-версий нет).
|
||||
с предустановленным qemu-guest-agent и фиксированным sha256; у RHEL-семейства
|
||||
там же снято штатное ограничение RPC, иначе агент отказывает в guest-exec (см.
|
||||
patch-cloud-images.sh). arm64-образы идут напрямую с upstream-серверов
|
||||
дистрибутивов (патченых arm64-версий нет).
|
||||
|
||||
LXC-шаблоны (vztmpl) здесь НЕ хардкодятся: их список берётся динамически из
|
||||
репозитория Proxmox (aplinfo), плюс произвольные vztmpl-зеркала из ImageMirror.
|
||||
@@ -16,6 +18,7 @@ from typing import Dict, List, Optional, Tuple
|
||||
|
||||
_GH = 'https://github.com/markmorado/pvemanager-images/releases/download/2026-08-14'
|
||||
_GH2 = 'https://github.com/markmorado/pvemanager-images/releases/download/2026-08-18'
|
||||
_GH3 = 'https://github.com/markmorado/pvemanager-images/releases/download/2026-08-22'
|
||||
|
||||
# (id_base, os, version, name, icon, {arch: (url, checksum_or_None)})
|
||||
# amd64 → собственное GitHub-зеркало + sha256; arm64 → upstream без checksum
|
||||
@@ -55,32 +58,32 @@ _CLOUD_IMAGES = [
|
||||
(
|
||||
'rocky-9-cloud', 'rocky', '9', 'Rocky Linux 9 GenericCloud', 'rocky',
|
||||
{
|
||||
'amd64': (f'{_GH}/Rocky-9-GenericCloud.latest.x86_64.qcow2',
|
||||
'a01fbfa6750c2a7f1c290f7e2ab51bfdbb564e95413f8f52ab27175a7646e30b'),
|
||||
'amd64': (f'{_GH3}/Rocky-9-GenericCloud.latest.x86_64.qcow2',
|
||||
'c15042de9805e2b9581715d46361484a2c94187a15608344f85e775fb34081f5'),
|
||||
'arm64': ('https://download.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud.latest.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'alma-9-cloud', 'almalinux', '9', 'AlmaLinux 9 GenericCloud', 'almalinux',
|
||||
{
|
||||
'amd64': (f'{_GH}/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2',
|
||||
'49c75c0b49d9831338d06288e7f751f98302144218b462e5d9b2b6fe25c66f5e'),
|
||||
'amd64': (f'{_GH3}/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2',
|
||||
'c3a21566c92c520c5ab9fa68e6ce38190ff173c531fbe0eea00573955d0b5131'),
|
||||
'arm64': ('https://repo.almalinux.org/almalinux/9/cloud/aarch64/images/AlmaLinux-9-GenericCloud-latest.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'alma-10-cloud', 'almalinux', '10', 'AlmaLinux 10 GenericCloud', 'almalinux',
|
||||
{
|
||||
'amd64': (f'{_GH2}/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2',
|
||||
'd0bdbc4fabb365b27e8d4a77118c1100ebdab42341312338e63724a2f9ee217f'),
|
||||
'amd64': (f'{_GH3}/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2',
|
||||
'7b91f6ad87aeac7193ece070e06e25406a76a7084d1628d89a5bb1ddd9d1642e'),
|
||||
'arm64': ('https://repo.almalinux.org/almalinux/10/cloud/aarch64/images/AlmaLinux-10-GenericCloud-latest.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'centos-stream-9-cloud', 'centos', '9-stream', 'CentOS Stream 9 GenericCloud', 'centos',
|
||||
{
|
||||
'amd64': (f'{_GH2}/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2',
|
||||
'63c13542e94678b0a41d318e7758f1cf23609564b1cbaa9051616457ce771903'),
|
||||
'amd64': (f'{_GH3}/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2',
|
||||
'd9818217c2ad20809973013f32094c2d82cbb74c08709afb4bef3d426b06dd37'),
|
||||
'arm64': ('https://cloud.centos.org/centos/9-stream/aarch64/images/CentOS-Stream-GenericCloud-9-latest.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
@@ -95,8 +98,8 @@ _CLOUD_IMAGES = [
|
||||
(
|
||||
'fedora-41-cloud', 'fedora', '41', 'Fedora 41 Cloud', 'fedora',
|
||||
{
|
||||
'amd64': (f'{_GH2}/Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2',
|
||||
'6c12ad70a8b5058f032de10d463bda8c93391162e5a72a90c79dd548ecc8e378'),
|
||||
'amd64': (f'{_GH3}/Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2',
|
||||
'40f101f44a83f714a1f586e6285f14a90e570c874a4bee2399873582b20dea80'),
|
||||
'arm64': ('https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-41-1.4.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -10,7 +10,9 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, text
|
||||
|
||||
from .models import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory
|
||||
from .models import (
|
||||
IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory, VMInstance, ProxmoxServer
|
||||
)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
@@ -18,6 +20,21 @@ def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
"""Привести значение к JSON-сериализуемому виду для колонок истории.
|
||||
|
||||
old_value/new_value — JSON-колонки, а в diff попадают и datetime-поля
|
||||
(last_seen, expires_at): без конвертации INSERT падает на json.dumps.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return {key: _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_safe(item) for item in value]
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -142,7 +159,8 @@ class IPAMService:
|
||||
self,
|
||||
network_id: int,
|
||||
pool_id: Optional[int] = None,
|
||||
prefer_sequential: bool = True
|
||||
prefer_sequential: bool = True,
|
||||
allow_network_scan: bool = False
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get the next available IP address from a network or specific pool.
|
||||
@@ -151,6 +169,9 @@ class IPAMService:
|
||||
network_id: Network ID
|
||||
pool_id: Optional specific pool ID
|
||||
prefer_sequential: If True, prefer sequential IPs; if False, can use any gap
|
||||
allow_network_scan: искать по всей подсети, когда auto-assign пулов
|
||||
нет. Нужно там, где адрес выдаётся точечно (доп. IP инстанса),
|
||||
а не по политике пула.
|
||||
|
||||
Returns:
|
||||
Available IP address string or None if no IPs available
|
||||
@@ -179,7 +200,7 @@ class IPAMService:
|
||||
IPAMPool.auto_assign == True
|
||||
).order_by(IPAMPool.id).all()
|
||||
|
||||
if not pools:
|
||||
if not pools and not allow_network_scan:
|
||||
logger.warning(f"No active auto-assign pools found for network {network_id}")
|
||||
return None
|
||||
|
||||
@@ -213,7 +234,34 @@ class IPAMService:
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid IP range in pool {pool.id}: {e}")
|
||||
continue
|
||||
|
||||
|
||||
# Пулов нет (или в них всё занято) — при явном разрешении идём по всей
|
||||
# подсети: адрес и сеть/broadcast исключает сам ip_network.hosts().
|
||||
if allow_network_scan and not pool_id:
|
||||
# Вне пула в подсети живут адреса, которых нет в IPAM: сами ноды
|
||||
# Proxmox и гости, чей адрес известен панели только из кэша.
|
||||
# Без этого скан спокойно предложил бы адрес работающей ноды.
|
||||
known = set(occupied)
|
||||
known.update(
|
||||
row[0] for row in self.db.query(VMInstance.ip_address).filter(
|
||||
VMInstance.ip_address.isnot(None),
|
||||
VMInstance.deleted_at.is_(None)
|
||||
).all() if row[0]
|
||||
)
|
||||
known.update(
|
||||
row[0] for row in self.db.query(ProxmoxServer.ip_address).filter(
|
||||
ProxmoxServer.ip_address.isnot(None)
|
||||
).all() if row[0]
|
||||
)
|
||||
try:
|
||||
for ip_obj in ipaddress.ip_network(network.network, strict=False).hosts():
|
||||
ip = str(ip_obj)
|
||||
if ip not in known:
|
||||
logger.info(f"Found available IP: {ip} in network {network.network}")
|
||||
return ip
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid network {network.network}: {e}")
|
||||
|
||||
logger.warning(f"No available IPs found in network {network_id}")
|
||||
return None
|
||||
|
||||
@@ -234,7 +282,11 @@ class IPAMService:
|
||||
allocation_type: str = "static",
|
||||
status: str = "allocated",
|
||||
allocated_by: Optional[str] = None,
|
||||
notes: Optional[str] = None
|
||||
notes: Optional[str] = None,
|
||||
is_primary: Optional[bool] = None,
|
||||
assignment_kind: str = "primary",
|
||||
target_interface: Optional[str] = None,
|
||||
apply_status: Optional[str] = None
|
||||
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
|
||||
"""
|
||||
Allocate an IP address.
|
||||
@@ -292,6 +344,16 @@ class IPAMService:
|
||||
# Build FQDN if not provided
|
||||
if not fqdn and hostname and network.dns_domain:
|
||||
fqdn = f"{hostname}.{network.dns_domain}"
|
||||
|
||||
# Первый адрес гостя становится основным сам: адрес выдаётся из шести
|
||||
# разных мест (мастер создания, шаблоны, ISO, appstore, ручная выдача),
|
||||
# и требовать флаг от каждого — значит рано или поздно его забыть.
|
||||
if is_primary is None:
|
||||
is_primary = (
|
||||
assignment_kind == "primary"
|
||||
and proxmox_vmid is not None
|
||||
and not self._has_primary(proxmox_server_id, proxmox_vmid)
|
||||
)
|
||||
|
||||
# Create allocation
|
||||
allocation = IPAMAllocation(
|
||||
@@ -310,7 +372,11 @@ class IPAMService:
|
||||
hostname=hostname,
|
||||
fqdn=fqdn,
|
||||
allocated_by=allocated_by,
|
||||
notes=notes
|
||||
notes=notes,
|
||||
is_primary=is_primary,
|
||||
assignment_kind=assignment_kind,
|
||||
target_interface=target_interface,
|
||||
apply_status=apply_status
|
||||
)
|
||||
|
||||
self.db.add(allocation)
|
||||
@@ -436,46 +502,51 @@ class IPAMService:
|
||||
reason: Optional[str] = None
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Release IP allocation by Proxmox VM/container ID.
|
||||
Used when VM/container is deleted.
|
||||
|
||||
Args:
|
||||
proxmox_server_id: Proxmox server ID
|
||||
proxmox_vmid: VM/container ID
|
||||
released_by: Username performing the release
|
||||
reason: Reason for release
|
||||
|
||||
Release every IP allocation of a Proxmox VM/container.
|
||||
Used when the VM/container is deleted.
|
||||
|
||||
У гостя может быть несколько адресов (основной + алиасы), поэтому
|
||||
освобождаются все: иначе после удаления гостя часть адресов навсегда
|
||||
оставалась бы занятой.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, error message or released IP)
|
||||
Tuple of (success, comma-separated released IPs or error message)
|
||||
"""
|
||||
# First try to find by proxmox_server_id and proxmox_vmid
|
||||
allocation = self.find_allocation_by_resource(proxmox_server_id, proxmox_vmid)
|
||||
|
||||
allocations = self.find_allocations_by_resource(proxmox_server_id, proxmox_vmid)
|
||||
|
||||
# Fallback: search by resource_id if proxmox fields are not set.
|
||||
# ВАЖНО: только среди записей БЕЗ proxmox_server_id — иначе удаление
|
||||
# VM с vmid=100 на сервере A освобождало IP виртуалки с тем же vmid
|
||||
# на сервере B (записи с заполненным server_id находит основной поиск).
|
||||
if not allocation:
|
||||
allocation = self.db.query(IPAMAllocation).filter(
|
||||
if not allocations:
|
||||
allocations = self.db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.resource_id == proxmox_vmid,
|
||||
IPAMAllocation.proxmox_server_id.is_(None),
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).first()
|
||||
if allocation:
|
||||
logger.debug(f"Found allocation by resource_id={proxmox_vmid} (legacy)")
|
||||
|
||||
if not allocation:
|
||||
).all()
|
||||
if allocations:
|
||||
logger.debug(f"Found allocation(s) by resource_id={proxmox_vmid} (legacy)")
|
||||
|
||||
if not allocations:
|
||||
logger.debug(f"No IPAM allocation found for server {proxmox_server_id}, vmid {proxmox_vmid}")
|
||||
return False, None # Not an error, just no allocation to release
|
||||
|
||||
ip_address = allocation.ip_address
|
||||
success, error = self.release_ip(ip_address, released_by, reason)
|
||||
|
||||
if success:
|
||||
logger.info(f"Auto-released IP {ip_address} for deleted VM/container {proxmox_vmid}")
|
||||
return True, ip_address
|
||||
|
||||
return False, error
|
||||
|
||||
released, errors = [], []
|
||||
for allocation in allocations:
|
||||
ip_address = allocation.ip_address
|
||||
success, error = self.release_ip(ip_address, released_by, reason)
|
||||
if success:
|
||||
released.append(ip_address)
|
||||
else:
|
||||
errors.append(error or ip_address)
|
||||
|
||||
if released:
|
||||
logger.info(
|
||||
f"Auto-released IP(s) {', '.join(released)} for deleted VM/container {proxmox_vmid}"
|
||||
)
|
||||
return True, ", ".join(released)
|
||||
|
||||
return False, "; ".join(errors) if errors else None
|
||||
|
||||
def update_allocation(
|
||||
self,
|
||||
@@ -537,17 +608,65 @@ class IPAMService:
|
||||
|
||||
return allocation, None
|
||||
|
||||
def find_allocations_by_resource(
|
||||
self,
|
||||
proxmox_server_id: int,
|
||||
proxmox_vmid: int
|
||||
) -> List[IPAMAllocation]:
|
||||
"""Все активные адреса гостя: основной первым, дальше по дате выдачи."""
|
||||
rows = self.db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.proxmox_server_id == proxmox_server_id,
|
||||
IPAMAllocation.proxmox_vmid == proxmox_vmid,
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).all()
|
||||
return sorted(rows, key=lambda a: (not bool(a.is_primary), a.id))
|
||||
|
||||
def find_allocation_by_resource(
|
||||
self,
|
||||
proxmox_server_id: int,
|
||||
proxmox_vmid: int
|
||||
) -> Optional[IPAMAllocation]:
|
||||
"""Find active allocation by Proxmox VM/container"""
|
||||
return self.db.query(IPAMAllocation).filter(
|
||||
"""Основной адрес гостя (у гостя их может быть несколько).
|
||||
|
||||
Если флаг is_primary нигде не проставлен — например, записи старше
|
||||
миграции multi-IP, — берём самую раннюю аллокацию.
|
||||
"""
|
||||
rows = self.find_allocations_by_resource(proxmox_server_id, proxmox_vmid)
|
||||
return rows[0] if rows else None
|
||||
|
||||
def set_primary(
|
||||
self,
|
||||
proxmox_server_id: int,
|
||||
proxmox_vmid: int,
|
||||
allocation_id: int,
|
||||
changed_by: Optional[str] = None
|
||||
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
|
||||
"""Сделать адрес основным, сняв флаг с прежнего. Основной ровно один."""
|
||||
rows = self.db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.proxmox_server_id == proxmox_server_id,
|
||||
IPAMAllocation.proxmox_vmid == proxmox_vmid,
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).first()
|
||||
IPAMAllocation.proxmox_vmid == proxmox_vmid
|
||||
).all()
|
||||
target = next((a for a in rows if a.id == allocation_id), None)
|
||||
if not target:
|
||||
return None, "Allocation does not belong to this guest"
|
||||
|
||||
for row in rows:
|
||||
row.is_primary = (row.id == allocation_id)
|
||||
|
||||
self._log_history(
|
||||
ip_address=target.ip_address,
|
||||
network_id=target.network_id,
|
||||
action="modified",
|
||||
new_value={'is_primary': True},
|
||||
resource_type=target.resource_type,
|
||||
resource_id=target.resource_id,
|
||||
resource_name=target.resource_name,
|
||||
performed_by=changed_by,
|
||||
notes="Marked as primary address"
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(target)
|
||||
return target, None
|
||||
|
||||
def release_all_by_server(self, proxmox_server_id: int, released_by: str = None) -> int:
|
||||
"""
|
||||
@@ -675,7 +794,27 @@ class IPAMService:
|
||||
return True, None
|
||||
|
||||
# ==================== Sync & Scan Operations ====================
|
||||
|
||||
|
||||
def _has_primary(self, server_id: Optional[int], vmid: Optional[int]) -> bool:
|
||||
"""Есть ли у гостя адрес, помеченный основным."""
|
||||
if vmid is None:
|
||||
return False
|
||||
return self.db.query(IPAMAllocation.id).filter(
|
||||
IPAMAllocation.proxmox_server_id == server_id,
|
||||
IPAMAllocation.proxmox_vmid == vmid,
|
||||
IPAMAllocation.is_primary.is_(True)
|
||||
).first() is not None
|
||||
|
||||
def _guest_exists(self, server_id: Optional[int], vmid: Optional[int]) -> bool:
|
||||
"""Существует ли ещё гость, за которым числится аллокация."""
|
||||
if server_id is None or vmid is None:
|
||||
return False
|
||||
return self.db.query(VMInstance.id).filter(
|
||||
VMInstance.server_id == server_id,
|
||||
VMInstance.vmid == vmid,
|
||||
VMInstance.deleted_at.is_(None)
|
||||
).first() is not None
|
||||
|
||||
def sync_from_proxmox_vm(
|
||||
self,
|
||||
network_id: int,
|
||||
@@ -686,18 +825,36 @@ class IPAMService:
|
||||
ip_address: str,
|
||||
node: str,
|
||||
mac_address: Optional[str] = None,
|
||||
synced_by: str = "system"
|
||||
synced_by: str = "system",
|
||||
target_interface: Optional[str] = None,
|
||||
assignment_kind: str = "primary"
|
||||
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
|
||||
"""
|
||||
Sync/create allocation for a Proxmox VM.
|
||||
Called when discovering VMs from Proxmox.
|
||||
|
||||
Гость может иметь несколько адресов: первый заведённый становится
|
||||
основным, последующие — обычными записями того же гостя.
|
||||
"""
|
||||
# Check if already allocated
|
||||
existing = self.db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.ip_address == ip_address
|
||||
).first()
|
||||
|
||||
|
||||
if existing:
|
||||
# Два живых гостя с одним адресом (например, устаревший ip= в конфиге
|
||||
# остановленного контейнера) не должны молча отбирать аллокацию друг
|
||||
# у друга: владелец сохраняется, конфликт возвращается вызывающему.
|
||||
belongs_to_other = (
|
||||
existing.proxmox_vmid is not None
|
||||
and (existing.proxmox_server_id, existing.proxmox_vmid) != (proxmox_server_id, vmid)
|
||||
)
|
||||
if belongs_to_other and self._guest_exists(existing.proxmox_server_id, existing.proxmox_vmid):
|
||||
return None, (
|
||||
f"IP {ip_address} is already linked to "
|
||||
f"{existing.resource_name or 'unknown'} (VMID {existing.proxmox_vmid}) — "
|
||||
f"duplicate address, resolve it in Proxmox first"
|
||||
)
|
||||
# Update existing allocation
|
||||
if existing.proxmox_vmid != vmid or existing.proxmox_server_id != proxmox_server_id:
|
||||
return self.update_allocation(
|
||||
@@ -725,7 +882,10 @@ class IPAMService:
|
||||
self.db.commit()
|
||||
return existing, None
|
||||
|
||||
# Create new allocation
|
||||
# Create new allocation. Основным первый адрес гостя пометит сам
|
||||
# allocate_ip — именно он попадёт в колонку IP списка инстансов.
|
||||
has_primary = self._has_primary(proxmox_server_id, vmid)
|
||||
|
||||
return self.allocate_ip(
|
||||
ip_address=ip_address,
|
||||
network_id=network_id,
|
||||
@@ -736,7 +896,10 @@ class IPAMService:
|
||||
proxmox_node=node,
|
||||
mac_address=mac_address,
|
||||
allocated_by=synced_by,
|
||||
notes="Auto-synced from Proxmox"
|
||||
notes="Auto-synced from Proxmox",
|
||||
is_primary=not has_primary,
|
||||
assignment_kind="primary" if not has_primary else assignment_kind,
|
||||
target_interface=target_interface
|
||||
)
|
||||
|
||||
def detect_conflicts(self, network_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
@@ -801,8 +964,8 @@ class IPAMService:
|
||||
ip_address=ip_address,
|
||||
network_id=network_id,
|
||||
action=action,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
old_value=_json_safe(old_value),
|
||||
new_value=_json_safe(new_value),
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
resource_name=resource_name,
|
||||
|
||||
@@ -87,6 +87,21 @@ class IPAMAllocation(Base):
|
||||
|
||||
status = Column(String(20), default="allocated", nullable=False)
|
||||
allocation_type = Column(String(20), default="static", nullable=False)
|
||||
|
||||
# Гость может держать несколько адресов: ровно один из них основной
|
||||
# (живёт в net0/ipconfig0 и показывается в колонке IP), остальные
|
||||
# навешиваются алиасами на интерфейс уже внутри гостя.
|
||||
is_primary = Column(Boolean, default=False, nullable=False)
|
||||
assignment_kind = Column(String(10), default="primary", nullable=False) # primary | alias
|
||||
target_interface = Column(String(20), nullable=True) # eth0, ens18, ...
|
||||
|
||||
# Состояние применения алиаса к живому гостю.
|
||||
# applied — поднят и закреплён в конфиге ОС; runtime_only — поднят, но
|
||||
# стек не распознан и ребут его не переживёт; pending — гость был
|
||||
# недоступен; failed — скрипт вернул ошибку.
|
||||
apply_status = Column(String(20), nullable=True)
|
||||
apply_error = Column(Text, nullable=True)
|
||||
applied_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
hostname = Column(String(255), nullable=True, index=True)
|
||||
fqdn = Column(String(255), nullable=True)
|
||||
@@ -106,6 +121,7 @@ class IPAMAllocation(Base):
|
||||
Index('idx_ipam_alloc_network', 'network_id'),
|
||||
Index('idx_ipam_alloc_resource', 'resource_type', 'resource_id'),
|
||||
Index('idx_ipam_alloc_proxmox', 'proxmox_server_id', 'proxmox_vmid'),
|
||||
Index('idx_ipam_alloc_primary', 'proxmox_server_id', 'proxmox_vmid', 'is_primary'),
|
||||
Index('idx_ipam_alloc_status', 'status'),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .client import ProxmoxClient, get_proxmox_resources, _run_in_executor, clear_all_cache, clear_server_cache
|
||||
from .client import ProxmoxClient, get_proxmox_resources, _run_in_executor, clear_all_cache, clear_server_cache, cleanup_expired_connections
|
||||
from .net_parser import ParsedNic, parse_net_string, parse_guest_nics
|
||||
|
||||
__all__ = [
|
||||
@@ -7,6 +7,7 @@ __all__ = [
|
||||
'_run_in_executor',
|
||||
'clear_all_cache',
|
||||
'clear_server_cache',
|
||||
'cleanup_expired_connections',
|
||||
'ParsedNic',
|
||||
'parse_net_string',
|
||||
'parse_guest_nics',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
import ipaddress
|
||||
import urllib3
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -10,6 +11,7 @@ from functools import lru_cache
|
||||
from contextlib import asynccontextmanager
|
||||
import base64
|
||||
|
||||
from .net_parser import parse_guest_nics
|
||||
from .mixins.vm import VmMixin
|
||||
from .mixins.lxc import LxcMixin
|
||||
from .mixins.cluster import ClusterMixin
|
||||
@@ -33,6 +35,27 @@ async def _run_in_executor(func, *args, **kwargs):
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(proxmox_executor, lambda: func(*args, **kwargs))
|
||||
|
||||
# qemu-guest-agent в образах RHEL-семейства (Rocky Linux, AlmaLinux, CentOS
|
||||
# Stream, Fedora) собран с урезанным набором RPC. Ограничение задаётся в
|
||||
# /etc/sysconfig/qemu-ga, откуда systemd-юнит берёт аргументы агента:
|
||||
# FILTER_RPC_ARGS с allow-list без guest-exec (актуальные пакеты) либо
|
||||
# BLOCK_RPCS / BLACKLIST_RPC с block-list (пакеты постарше). Proxmox отдаёт
|
||||
# это как "The command guest-exec has been disabled for this instance" — по
|
||||
# такому тексту не догадаться, что и где чинить, поэтому дописываем инструкцию.
|
||||
def agent_error_hint(error: str) -> str:
|
||||
"""Дополнить ошибку guest agent подсказкой, если в госте запрещён RPC."""
|
||||
low = (error or '').lower()
|
||||
if 'has been disabled' in low and 'guest-exec' in low:
|
||||
return (
|
||||
f'{error}. В гостевой ОС запрещён RPC guest-exec — так по умолчанию '
|
||||
'собран qemu-guest-agent в Rocky Linux, AlmaLinux, CentOS Stream и '
|
||||
'Fedora. Внутри ВМ очистите в /etc/sysconfig/qemu-ga значение '
|
||||
'FILTER_RPC_ARGS (в пакетах постарше — BLOCK_RPCS или '
|
||||
'BLACKLIST_RPC) и выполните systemctl restart qemu-guest-agent'
|
||||
)
|
||||
return error
|
||||
|
||||
|
||||
def get_proxmox_resources(host: str, user: str = "root@pam",
|
||||
password: str = None, token_name: str = None,
|
||||
token_value: str = None, verify_ssl: bool = False,
|
||||
@@ -1642,62 +1665,89 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
logger.debug(f"netstat unavailable for node {node}: {e}")
|
||||
return []
|
||||
|
||||
def get_container_interfaces(self, node: str, vmid: int) -> List[Dict]:
|
||||
def get_container_interfaces(self, node: str, vmid: int,
|
||||
include_live: bool = True) -> List[Dict]:
|
||||
"""
|
||||
Получить сетевые интерфейсы контейнера
|
||||
|
||||
Получить сетевые интерфейсы контейнера с их IP адресами.
|
||||
|
||||
LXC держит адрес внутри строки netN
|
||||
("name=eth0,bridge=vmbr0,hwaddr=..,ip=10.0.0.5/24"); ключа ipconfigN у
|
||||
контейнеров не бывает — это cloud-init QEMU. При ip=dhcp статики в
|
||||
конфиге нет вовсе, поэтому фактические адреса запущенного контейнера
|
||||
дочитываются из /nodes/{node}/lxc/{vmid}/interfaces.
|
||||
|
||||
Args:
|
||||
node: Имя ноды
|
||||
vmid: ID контейнера
|
||||
|
||||
include_live: опрашивать ли /interfaces (бесполезно для остановленного CT)
|
||||
|
||||
Returns:
|
||||
Список интерфейсов с IP адресами
|
||||
Список интерфейсов: {'name', 'hardware_address', 'ips': [{'address', 'type', 'prefix'}]}
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
|
||||
|
||||
interfaces: Dict[str, Dict] = {}
|
||||
|
||||
def _slot(name: str, mac: str = '') -> Dict:
|
||||
iface = interfaces.setdefault(
|
||||
name, {'name': name, 'hardware_address': '', 'ips': []}
|
||||
)
|
||||
if mac and not iface['hardware_address']:
|
||||
iface['hardware_address'] = mac
|
||||
return iface
|
||||
|
||||
def _add_ip(iface: Dict, value: str) -> None:
|
||||
"""Принимает '10.0.0.5/24' либо '10.0.0.5'; мусор и dhcp игнорирует."""
|
||||
addr, _, prefix = (value or '').strip().partition('/')
|
||||
if not addr or addr in ('dhcp', 'manual', 'auto'):
|
||||
return
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(addr)
|
||||
except ValueError:
|
||||
return
|
||||
if ip_obj.is_loopback or ip_obj.is_link_local:
|
||||
return
|
||||
if any(existing['address'] == addr for existing in iface['ips']):
|
||||
return
|
||||
iface['ips'].append({
|
||||
'address': addr,
|
||||
'type': 'ipv6' if ip_obj.version == 6 else 'ipv4',
|
||||
'prefix': int(prefix) if prefix.isdigit() else (128 if ip_obj.version == 6 else 32),
|
||||
})
|
||||
|
||||
# 1) Статика из конфига контейнера
|
||||
try:
|
||||
# Для LXC можем получить IP из конфигурации
|
||||
config = self.proxmox.nodes(node).lxc(vmid).config.get()
|
||||
interfaces = []
|
||||
|
||||
# Проверяем net0, net1 и т.д.
|
||||
for i in range(10):
|
||||
net_key = f'net{i}'
|
||||
if net_key in config:
|
||||
net_config = config[net_key]
|
||||
# Парсим строку конфигурации сети
|
||||
iface_info = {'name': f'eth{i}', 'ips': []}
|
||||
|
||||
# Пытаемся извлечь MAC адрес
|
||||
if 'hwaddr=' in net_config:
|
||||
mac = net_config.split('hwaddr=')[1].split(',')[0]
|
||||
iface_info['hardware_address'] = mac
|
||||
|
||||
interfaces.append(iface_info)
|
||||
|
||||
# Также проверяем IP из конфигурации
|
||||
for i in range(10):
|
||||
ipconfig_key = f'ipconfig{i}'
|
||||
if ipconfig_key in config and interfaces and i < len(interfaces):
|
||||
ipconfig = config[ipconfig_key]
|
||||
# Парсим ip=192.168.1.10/24,gw=192.168.1.1
|
||||
if 'ip=' in ipconfig:
|
||||
ip_part = ipconfig.split('ip=')[1].split(',')[0]
|
||||
if ip_part != 'dhcp':
|
||||
# Разделяем IP и префикс
|
||||
if '/' in ip_part:
|
||||
ip, prefix = ip_part.split('/')
|
||||
interfaces[i]['ips'].append({
|
||||
'address': ip,
|
||||
'type': 'ipv4',
|
||||
'prefix': int(prefix)
|
||||
})
|
||||
|
||||
return [iface for iface in interfaces if iface.get('ips')]
|
||||
for nic in parse_guest_nics(config):
|
||||
iface = _slot(nic.name or f'eth{nic.index}', nic.mac or '')
|
||||
_add_ip(iface, nic.ip or '')
|
||||
_add_ip(iface, nic.ip6 or '')
|
||||
except Exception as e:
|
||||
logger.debug(f"Не удалось получить сетевые интерфейсы контейнера {vmid}: {e}")
|
||||
return []
|
||||
logger.debug(f"Не удалось прочитать конфиг контейнера {vmid}: {e}")
|
||||
|
||||
# 2) Живые адреса — единственный источник для DHCP-контейнеров.
|
||||
# Если статика уже найдена, лишний запрос не делаем: у остановленного
|
||||
# контейнера этот эндпоинт всё равно отдаёт ошибку.
|
||||
has_static_v4 = any(
|
||||
ip_info['type'] == 'ipv4'
|
||||
for iface in interfaces.values() for ip_info in iface['ips']
|
||||
)
|
||||
if not has_static_v4 and include_live:
|
||||
try:
|
||||
for iface_data in (self.proxmox.nodes(node).lxc(vmid).interfaces.get() or []):
|
||||
name = iface_data.get('name') or ''
|
||||
if not name or name == 'lo':
|
||||
continue
|
||||
iface = _slot(name, (iface_data.get('hwaddr') or '').upper())
|
||||
_add_ip(iface, iface_data.get('inet') or '')
|
||||
_add_ip(iface, iface_data.get('inet6') or '')
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Интерфейсы контейнера {vmid} недоступны (возможно, остановлен): {e}"
|
||||
)
|
||||
|
||||
return [iface for iface in interfaces.values() if iface['ips']]
|
||||
|
||||
def execute_command(self, node: str, vmid: int, command: str, timeout: int = 30) -> Dict:
|
||||
"""
|
||||
@@ -1745,7 +1795,7 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
logger.error(f"Failed to execute command on VM {vmid}: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'error': agent_error_hint(str(e)),
|
||||
'stdout': '',
|
||||
'stderr': '',
|
||||
'exit_code': -1
|
||||
@@ -1823,9 +1873,16 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
#
|
||||
# Proxmox API сам кодирует input-data в base64 перед передачей
|
||||
# guest agent'у, поэтому передаем сырое содержимое скрипта.
|
||||
# Proxmox кодирует input-data в base64 силами Perl, а тот падает
|
||||
# с "Wide character in subroutine entry" на любой строке с
|
||||
# не-ASCII — например на комментариях кириллицей. Отдаём те же
|
||||
# UTF-8 байты, представленные как latin-1: в JSON уходят байты,
|
||||
# гость получает корректный UTF-8.
|
||||
payload = script_content.encode('utf-8').decode('latin-1')
|
||||
|
||||
exec_result = self.proxmox.nodes(node).qemu(vmid).agent.exec.post(
|
||||
command=[interpreter],
|
||||
**{'input-data': script_content}
|
||||
**{'input-data': payload}
|
||||
)
|
||||
|
||||
if 'pid' not in exec_result:
|
||||
@@ -1843,7 +1900,7 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
logger.error(f"Failed to execute script on VM {vmid}: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'error': agent_error_hint(str(e)),
|
||||
'stdout': '',
|
||||
'stderr': '',
|
||||
'exit_code': -1
|
||||
|
||||
@@ -239,6 +239,16 @@ class StorageMixin:
|
||||
logger.error(f"Ошибка получения статусов хранилищ с {self.host}: {e}")
|
||||
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]:
|
||||
"""Get storages available on a specific node, optionally filtered by content type"""
|
||||
if not self.proxmox:
|
||||
|
||||
@@ -137,12 +137,10 @@ NEW_DEFAULT_ROLES = [
|
||||
"template:view": True,
|
||||
"storage:view": True,
|
||||
"firewall:view": True,
|
||||
"network:view": True,
|
||||
"node:view": True,
|
||||
"pool:view": True,
|
||||
"backup:view": True,
|
||||
"backup:create": True,
|
||||
"ipam:view": True,
|
||||
"user:view": True,
|
||||
"log:view": True,
|
||||
"log:export": True,
|
||||
@@ -172,10 +170,8 @@ NEW_DEFAULT_ROLES = [
|
||||
"template:view": True,
|
||||
"storage:view": True,
|
||||
"firewall:view": True,
|
||||
"network:view": True,
|
||||
"node:view": True,
|
||||
"pool:view": True,
|
||||
"ipam:view": True,
|
||||
# No setting:view — that grants the panel-wide settings page. A
|
||||
# user's own profile lives at /profile and needs no permission.
|
||||
"notification:view": True,
|
||||
@@ -195,10 +191,8 @@ NEW_DEFAULT_ROLES = [
|
||||
"template:view": True,
|
||||
"storage:view": True,
|
||||
"firewall:view": True,
|
||||
"network:view": True,
|
||||
"node: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.
|
||||
GRANULAR_BACKFILL = [
|
||||
# (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"]),
|
||||
("server:manage", [
|
||||
"firewall:view", "firewall:manage",
|
||||
|
||||
@@ -643,6 +643,13 @@ class IPAMAllocationResponse(IPAMAllocationBase):
|
||||
last_seen: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# Multi-IP: гость может держать несколько адресов
|
||||
is_primary: bool = False
|
||||
assignment_kind: str = "primary"
|
||||
target_interface: Optional[str] = None
|
||||
apply_status: Optional[str] = None
|
||||
apply_error: Optional[str] = None
|
||||
applied_at: Optional[datetime] = None
|
||||
# Extended info
|
||||
network_name: Optional[str] = None
|
||||
pool_name: Optional[str] = None
|
||||
|
||||
220
backend/app/services/guest_ip/sync_aliases.sh
Normal file
220
backend/app/services/guest_ip/sync_aliases.sh
Normal file
@@ -0,0 +1,220 @@
|
||||
#!/bin/sh
|
||||
# Bring the set of extra IPv4 aliases of an interface to the requested one.
|
||||
#
|
||||
# Parameters arrive as environment variables (rendered by
|
||||
# script_engine.render_params, values are never concatenated into commands):
|
||||
# IFACE - guest interface, e.g. eth0
|
||||
# ADDRESSES - space separated CIDR list; empty string removes every alias
|
||||
# PVEM_ROOT - path prefix; always empty in a guest, set by tests only
|
||||
#
|
||||
# The script is idempotent: it synchronises state instead of appending.
|
||||
# Only addresses listed in its own state file are managed, so anything
|
||||
# configured inside the guest by hand stays untouched.
|
||||
#
|
||||
# Networking is deliberately NOT restarted: the address goes up via
|
||||
# `ip addr add` right away, and the OS config is edited only so that the
|
||||
# address survives a reboot. No `netplan apply` / `ifup` / `nmcli con up`:
|
||||
# they would cut the connection to a running guest.
|
||||
|
||||
[ -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. Remove addresses dropped from the list (ours only, per state file)
|
||||
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. Bring up the missing ones
|
||||
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. Persist in the OS config - the first recognised stack wins.
|
||||
# Branches run in sequence rather than as elif: having a tool installed
|
||||
# does not mean it manages this interface (NetworkManager inside an LXC
|
||||
# is the usual case), and then control must fall through to the next one.
|
||||
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
|
||||
# Proxmox LXC templates create interfaces.d but never source it from
|
||||
# /etc/network/interfaces, so files there are ignored; add the source
|
||||
# line (idempotently). The interfaces file itself is left alone:
|
||||
# Proxmox rewrites it whenever the NIC is edited.
|
||||
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 - do not edit by hand"
|
||||
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 images) ---
|
||||
if [ "$persist" = none ] && [ -d "$NETPLAN_DIR" ]; then
|
||||
if [ -z "$ADDRESSES" ]; then
|
||||
rm -f "$NETPLAN_FILE"
|
||||
else
|
||||
{
|
||||
echo "$MARKER - do not edit by hand"
|
||||
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 VMs, modern desktop images) ---
|
||||
if [ "$persist" = none ] && command -v nmcli >/dev/null 2>&1; then
|
||||
# Empty answer means NM does not manage the interface - fall through.
|
||||
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) ---
|
||||
# Proxmox configures LXC templates of these distros exactly this way
|
||||
# (pve-container, Redhat.pm), and NetworkManager is usually absent there.
|
||||
if [ "$persist" = none ] && [ -d "$SYSCONFIG_DIR" ]; then
|
||||
# Drop our previous ifcfg aliases; files without the marker stay.
|
||||
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 - do not edit by hand"
|
||||
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
|
||||
|
||||
# --- systemd unit: survives Proxmox rewriting the guest network config ---
|
||||
# Proxmox regenerates the guest network configuration on every container
|
||||
# start (pve-container writes /etc/network/interfaces, ifcfg or the NM
|
||||
# keyfile from netN), which wipes any alias we added to the native stack.
|
||||
# A tiny oneshot unit re-adds the addresses from the state file after boot,
|
||||
# and /etc/systemd is not touched by Proxmox.
|
||||
HELPER=$ROOT/usr/local/sbin/pvemanager-aliases
|
||||
UNIT=$ROOT/etc/systemd/system/pvemanager-aliases.service
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -d "$ROOT/etc/systemd/system" ]; then
|
||||
# Is there anything left to restore on any interface at all
|
||||
remaining=$(cat "$STATE_DIR"/aliases.* 2>/dev/null | tr -d '[:space:]')
|
||||
|
||||
if [ -z "$remaining" ]; then
|
||||
systemctl disable pvemanager-aliases.service >/dev/null 2>&1 || true
|
||||
rm -f "$UNIT" "$HELPER"
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
else
|
||||
mkdir -p "$ROOT/usr/local/sbin" 2>/dev/null || true
|
||||
cat > "$HELPER" <<'HELPER_EOF'
|
||||
#!/bin/sh
|
||||
# managed by PVEmanager - do not edit by hand
|
||||
# Re-add the alias addresses recorded by PVEmanager for every interface.
|
||||
for state in /var/lib/pvemanager/aliases.*; do
|
||||
[ -f "$state" ] || continue
|
||||
iface=${state##*/aliases.}
|
||||
for cidr in $(cat "$state"); do
|
||||
addr=${cidr%%/*}
|
||||
ip -4 addr show dev "$iface" 2>/dev/null | grep -qw "$addr" && continue
|
||||
ip addr add "$cidr" dev "$iface" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
HELPER_EOF
|
||||
chmod 755 "$HELPER"
|
||||
|
||||
cat > "$UNIT" <<'UNIT_EOF'
|
||||
# managed by PVEmanager - do not edit by hand
|
||||
[Unit]
|
||||
Description=PVEmanager extra IP addresses
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/sbin/pvemanager-aliases
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT_EOF
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl enable pvemanager-aliases.service >/dev/null 2>&1 || true
|
||||
persist="${persist}+systemd"
|
||||
[ "$persist" = "none+systemd" ] && persist=systemd
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "PVEMANAGER_PERSIST=$persist"
|
||||
echo "PVEMANAGER_OK=1"
|
||||
224
backend/app/services/guest_ip_service.py
Normal file
224
backend/app/services/guest_ip_service.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Дополнительные 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" # скрипт отработал с ошибкой
|
||||
|
||||
# Значение может быть составным ("nm+systemd"), когда родной стек
|
||||
# дополнен подстраховкой systemd-юнитом.
|
||||
_PERSIST_RE = re.compile(r"^PVEMANAGER_PERSIST=([\w+]+)$", re.MULTILINE)
|
||||
_OK_RE = re.compile(r"^PVEMANAGER_OK=1$", re.MULTILINE)
|
||||
_IFACE_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,15}$")
|
||||
|
||||
DEFAULT_INTERFACE = "eth0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyResult:
|
||||
success: bool
|
||||
status: str
|
||||
persist: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
output: str = ""
|
||||
|
||||
|
||||
def _load_script() -> str:
|
||||
return _SCRIPT_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def prefix_for(db: Session, allocation: IPAMAllocation) -> Optional[int]:
|
||||
"""Длина префикса адреса — берётся из сети, которой он принадлежит."""
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == allocation.network_id).first()
|
||||
if not network or not network.network:
|
||||
return None
|
||||
try:
|
||||
return ipaddress.ip_network(network.network, strict=False).prefixlen
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def alias_allocations(db: Session, server_id: int, vmid: int,
|
||||
interface: str) -> List[IPAMAllocation]:
|
||||
"""Все alias-адреса гостя на конкретном интерфейсе, по возрастанию адреса."""
|
||||
rows = db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.proxmox_server_id == server_id,
|
||||
IPAMAllocation.proxmox_vmid == vmid,
|
||||
IPAMAllocation.assignment_kind == "alias",
|
||||
IPAMAllocation.target_interface == interface,
|
||||
IPAMAllocation.status.in_(["allocated", "reserved"]),
|
||||
).all()
|
||||
|
||||
def _sort_key(row: IPAMAllocation):
|
||||
try:
|
||||
return (0, int(ipaddress.ip_address(row.ip_address)))
|
||||
except ValueError:
|
||||
return (1, 0)
|
||||
|
||||
return sorted(rows, key=_sort_key)
|
||||
|
||||
|
||||
def _guest_target(db: Session, server_id: int, vmid: int,
|
||||
allocation: Optional[IPAMAllocation] = None
|
||||
) -> Tuple[Optional[ProxmoxServer], Optional[str], Optional[str]]:
|
||||
"""(server, node, vm_type) для запуска скрипта; None-ы, если гость неизвестен."""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
guest = db.query(VMInstance).filter(
|
||||
VMInstance.server_id == server_id,
|
||||
VMInstance.vmid == vmid,
|
||||
VMInstance.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
node = (guest.node if guest else None) or (allocation.proxmox_node if allocation else None)
|
||||
vm_type = guest.vm_type if guest else None
|
||||
if not vm_type and allocation and allocation.resource_type in ("qemu", "lxc"):
|
||||
vm_type = allocation.resource_type
|
||||
return server, node, vm_type
|
||||
|
||||
|
||||
def resolve_interface(client, vm_type: str, node: str, vmid: int,
|
||||
primary_ip: Optional[str] = None) -> str:
|
||||
"""Интерфейс по умолчанию — тот, на котором висит основной адрес гостя.
|
||||
|
||||
Данные берём у существующих методов клиента; если гость молчит,
|
||||
возвращаем eth0 — пользователь всё равно может выбрать интерфейс вручную.
|
||||
"""
|
||||
try:
|
||||
if vm_type == "lxc":
|
||||
interfaces = client.get_container_interfaces(node, vmid)
|
||||
else:
|
||||
interfaces = client.get_vm_interfaces(node, vmid)
|
||||
except Exception as e:
|
||||
logger.debug(f"[guest-ip] не удалось получить интерфейсы {vmid}: {e}")
|
||||
return DEFAULT_INTERFACE
|
||||
|
||||
fallback = None
|
||||
for iface in interfaces or []:
|
||||
name = iface.get("name") or ""
|
||||
if not name or name == "lo":
|
||||
continue
|
||||
fallback = fallback or name
|
||||
if primary_ip:
|
||||
for ip_info in iface.get("ips") or []:
|
||||
if ip_info.get("address") == primary_ip:
|
||||
return name
|
||||
return fallback or DEFAULT_INTERFACE
|
||||
|
||||
|
||||
def sync_interface(db: Session, server_id: int, vmid: int, interface: str,
|
||||
allocation: Optional[IPAMAllocation] = None) -> ApplyResult:
|
||||
"""Привести alias-адреса интерфейса в госте к тому, что записано в IPAM.
|
||||
|
||||
Обновляет apply_status у всех alias-аллокаций этого интерфейса: они
|
||||
применяются одним вызовом, значит и исход у них общий.
|
||||
"""
|
||||
if not _IFACE_RE.match(interface or ""):
|
||||
return ApplyResult(False, FAILED, error=f"Некорректное имя интерфейса: {interface}")
|
||||
|
||||
server, node, vm_type = _guest_target(db, server_id, vmid, allocation)
|
||||
rows = alias_allocations(db, server_id, vmid, interface)
|
||||
|
||||
if not server:
|
||||
return _finish(db, rows, ApplyResult(False, PENDING, error="Сервер Proxmox не найден"))
|
||||
if not node or not vm_type:
|
||||
return _finish(db, rows, ApplyResult(
|
||||
False, PENDING, error="Гость не найден в кэше — синхронизируйте инстансы"))
|
||||
|
||||
addresses = []
|
||||
for row in rows:
|
||||
prefix = prefix_for(db, row)
|
||||
if prefix is None:
|
||||
return _finish(db, rows, ApplyResult(
|
||||
False, FAILED,
|
||||
error=f"Не удалось определить префикс сети для {row.ip_address}"))
|
||||
addresses.append(f"{row.ip_address}/{prefix}")
|
||||
|
||||
script = script_engine.render_params(
|
||||
_load_script(), {"IFACE": interface, "ADDRESSES": " ".join(addresses)}
|
||||
)
|
||||
result = script_engine.execute(
|
||||
server, target_type="guest", node=node, vmid=vmid, vm_type=vm_type,
|
||||
script=script, interpreter="/bin/sh", timeout=_SYNC_TIMEOUT,
|
||||
)
|
||||
|
||||
output = result.output or ""
|
||||
if not result.success or not _OK_RE.search(output):
|
||||
error = (result.error or output or "").strip()[:1000] or "Скрипт не завершился успешно"
|
||||
# exit_code == -1 — транспорт: гость выключен, нет SSH к ноде или нет
|
||||
# guest agent. Это не ошибка конфигурации, поэтому pending: адрес
|
||||
# закреплён в IPAM и применится, когда гость станет доступен.
|
||||
status = PENDING if result.exit_code == -1 else FAILED
|
||||
return _finish(db, rows, ApplyResult(False, status, error=error, output=output))
|
||||
|
||||
persist_match = _PERSIST_RE.search(output)
|
||||
persist = persist_match.group(1) if persist_match else "none"
|
||||
status = RUNTIME_ONLY if persist == "none" else APPLIED
|
||||
return _finish(db, rows, ApplyResult(True, status, persist=persist, output=output))
|
||||
|
||||
|
||||
def _finish(db: Session, rows: List[IPAMAllocation], result: ApplyResult) -> ApplyResult:
|
||||
"""Записать исход применения во все затронутые аллокации."""
|
||||
for row in rows:
|
||||
row.apply_status = result.status
|
||||
row.apply_error = result.error
|
||||
if result.success:
|
||||
row.applied_at = utcnow()
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
|
||||
def apply_address(db: Session, allocation: IPAMAllocation) -> ApplyResult:
|
||||
"""Применить (или переприменить) alias-адрес к гостю."""
|
||||
if allocation.assignment_kind != "alias":
|
||||
return ApplyResult(True, APPLIED, persist="primary",
|
||||
output="Основной адрес задаётся конфигом Proxmox")
|
||||
return sync_interface(
|
||||
db, allocation.proxmox_server_id, allocation.proxmox_vmid,
|
||||
allocation.target_interface or DEFAULT_INTERFACE, allocation,
|
||||
)
|
||||
|
||||
|
||||
def remove_address(db: Session, allocation: IPAMAllocation) -> ApplyResult:
|
||||
"""Снять alias-адрес с гостя.
|
||||
|
||||
К моменту вызова аллокация должна быть уже освобождена или удалена —
|
||||
скрипт снимает всё, чего нет в переданном ему списке.
|
||||
"""
|
||||
if allocation.assignment_kind != "alias":
|
||||
return ApplyResult(True, APPLIED, persist="primary")
|
||||
return sync_interface(
|
||||
db, allocation.proxmox_server_id, allocation.proxmox_vmid,
|
||||
allocation.target_interface or DEFAULT_INTERFACE, allocation,
|
||||
)
|
||||
@@ -5,6 +5,7 @@ Uses APScheduler for periodic tasks
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
from loguru import logger
|
||||
@@ -108,12 +109,17 @@ class MonitoringWorker:
|
||||
# задачу failed. Транзитный сетевой сбой не должен «убивать» живую задачу.
|
||||
UPID_FAIL_THRESHOLD = 3
|
||||
|
||||
# Как часто перепроверять IP гостя у Proxmox. Синк идёт каждые 10 секунд,
|
||||
# а адрес меняется редко — без троттлинга это сотни лишних запросов в минуту.
|
||||
IP_DETECT_TTL = 300
|
||||
|
||||
def __init__(self):
|
||||
self.last_vm_states: Dict[str, str] = {} # vm_id -> status
|
||||
self.last_resource_alerts: Dict[str, float] = {} # resource_id -> last_alert_time
|
||||
self.last_server_states: Dict[int, bool] = {} # server_id -> is_online
|
||||
self.last_server_alerts: Dict[int, float] = {} # server_id -> last_alert_time
|
||||
self.upid_fail_counts: Dict[int, int] = {} # ProxmoxTask.id -> consecutive fetch failures
|
||||
self.last_ip_detect: Dict[tuple, float] = {} # (server_id, vmid) -> monotonic time
|
||||
|
||||
def _create_proxmox_client(self, server: ProxmoxServer) -> ProxmoxClient:
|
||||
"""Create ProxmoxClient from server model, raising if auth is missing."""
|
||||
@@ -619,18 +625,20 @@ class MonitoringWorker:
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).all()
|
||||
|
||||
# Build IPAM lookups
|
||||
# Build IPAM lookups.
|
||||
# Только по идентификатору гостя: сопоставление по имени давало чужой
|
||||
# адрес одноимённым гостям на разных серверах (два разных "wireguard").
|
||||
# У гостя может быть несколько адресов — в кэш инстанса пишем
|
||||
# основной (is_primary); без флага берём самую раннюю запись.
|
||||
ipam_by_server_vmid = {} # (server_id, vmid) -> allocation
|
||||
ipam_by_name = {} # hostname/resource_name -> allocation
|
||||
|
||||
|
||||
for alloc in ipam_allocations:
|
||||
if alloc.proxmox_server_id and alloc.proxmox_vmid:
|
||||
ipam_by_server_vmid[(alloc.proxmox_server_id, alloc.proxmox_vmid)] = alloc
|
||||
if alloc.hostname:
|
||||
ipam_by_name[alloc.hostname.lower()] = alloc
|
||||
if alloc.resource_name:
|
||||
ipam_by_name[alloc.resource_name.lower()] = alloc
|
||||
|
||||
key = (alloc.proxmox_server_id, alloc.proxmox_vmid)
|
||||
current = ipam_by_server_vmid.get(key)
|
||||
if current is None or (alloc.is_primary and not current.is_primary):
|
||||
ipam_by_server_vmid[key] = alloc
|
||||
|
||||
# Detect clusters: servers that see multiple nodes are part of a cluster
|
||||
# Group servers by their node set
|
||||
server_nodes = {} # server_id -> set of node names
|
||||
@@ -749,26 +757,34 @@ class MonitoringWorker:
|
||||
all_seen.add((server_id, vmid))
|
||||
|
||||
# Get IP from IPAM
|
||||
ipam_alloc = (
|
||||
ipam_by_server_vmid.get((server_id, vmid)) or
|
||||
ipam_by_name.get(vm_name.lower())
|
||||
)
|
||||
ipam_alloc = ipam_by_server_vmid.get((server_id, vmid))
|
||||
ip_address = ipam_alloc.ip_address if ipam_alloc else None
|
||||
|
||||
# If no IPAM IP, try to detect from Proxmox (LXC config or QEMU guest agent)
|
||||
if not ip_address:
|
||||
# If no IPAM IP, try to detect from Proxmox (LXC config or QEMU guest agent).
|
||||
# Троттлим: адрес гостя меняется куда реже, чем идёт синк.
|
||||
detect_key = (server_id, vmid)
|
||||
detect_due = (time.monotonic() - self.last_ip_detect.get(detect_key, 0.0)) >= self.IP_DETECT_TTL
|
||||
if not ip_address and detect_due:
|
||||
self.last_ip_detect[detect_key] = time.monotonic()
|
||||
try:
|
||||
px_client = server_clients.get(server_id)
|
||||
if px_client and node_name:
|
||||
if vm_type == 'lxc':
|
||||
config = px_client.proxmox.nodes(node_name).lxc(vmid).config.get()
|
||||
for i in range(4):
|
||||
ipconfig = config.get(f'ipconfig{i}', '')
|
||||
if 'ip=' in str(ipconfig):
|
||||
ip_part = str(ipconfig).split('ip=')[1].split(',')[0]
|
||||
if ip_part and ip_part != 'dhcp':
|
||||
ip_address = ip_part.split('/')[0] if '/' in ip_part else ip_part
|
||||
# Статика из netN + живые адреса запущенного
|
||||
# контейнера (DHCP виден только там)
|
||||
for iface in px_client.get_container_interfaces(
|
||||
node_name, vmid,
|
||||
include_live=vm_data.get('status') == 'running',
|
||||
):
|
||||
for ip_info in iface.get('ips') or []:
|
||||
if ip_info.get('type') != 'ipv4':
|
||||
continue
|
||||
addr = ip_info.get('address', '')
|
||||
if addr:
|
||||
ip_address = addr
|
||||
break
|
||||
if ip_address:
|
||||
break
|
||||
elif vm_type == 'qemu' and vm_data.get('status') == 'running':
|
||||
result = px_client.proxmox.nodes(node_name).qemu(vmid).agent('network-get-interfaces').get()
|
||||
if result and 'result' in result:
|
||||
@@ -862,6 +878,11 @@ class MonitoringWorker:
|
||||
db.rollback()
|
||||
return
|
||||
|
||||
# Не копим троттлинг-метки по гостям, которых больше нет
|
||||
self.last_ip_detect = {
|
||||
key: seen_at for key, seen_at in self.last_ip_detect.items() if key in all_seen
|
||||
}
|
||||
|
||||
# Mark VMs that no longer exist as deleted
|
||||
# Get all active VMs from synced servers
|
||||
synced_server_ids = set(server.id for server in servers)
|
||||
|
||||
@@ -1838,6 +1838,48 @@ def migrate_app_operations_server_id(conn):
|
||||
logger.info("✓ app_operations.server_id already exists")
|
||||
|
||||
|
||||
def migrate_ipam_multi_ip(conn):
|
||||
"""Migration 43: несколько IP-адресов на одного гостя.
|
||||
|
||||
Раньше на (server_id, vmid) подразумевалась ровно одна аллокация. Теперь
|
||||
их может быть несколько: один основной (net0/ipconfig0, попадает в колонку
|
||||
IP) и произвольное число алиасов, навешенных внутри гостя. Колонки
|
||||
apply_* хранят состояние применения алиаса к живому гостю.
|
||||
"""
|
||||
if not table_exists(conn, 'ipam_allocations'):
|
||||
logger.info("Table ipam_allocations does not exist, skipping multi-IP migration")
|
||||
return
|
||||
|
||||
added = False
|
||||
for column, ddl in (
|
||||
('is_primary', 'BOOLEAN NOT NULL DEFAULT FALSE'),
|
||||
('assignment_kind', "VARCHAR(10) NOT NULL DEFAULT 'primary'"),
|
||||
('target_interface', 'VARCHAR(20)'),
|
||||
('apply_status', 'VARCHAR(20)'),
|
||||
('apply_error', 'TEXT'),
|
||||
('applied_at', 'TIMESTAMP WITH TIME ZONE'),
|
||||
):
|
||||
if add_column_if_not_exists(conn, 'ipam_allocations', column, ddl):
|
||||
added = True
|
||||
|
||||
if added:
|
||||
conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ipam_alloc_primary "
|
||||
"ON ipam_allocations(proxmox_server_id, proxmox_vmid, is_primary)"
|
||||
))
|
||||
# Всё, что существовало до этой миграции, было единственным адресом
|
||||
# своего гостя — значит основным. Записи без привязки к гостю
|
||||
# (ручные брони) основными не считаем.
|
||||
conn.execute(text("""
|
||||
UPDATE ipam_allocations
|
||||
SET is_primary = TRUE, assignment_kind = 'primary'
|
||||
WHERE proxmox_vmid IS NOT NULL AND is_primary = FALSE
|
||||
"""))
|
||||
logger.info("✓ Added multi-IP columns to ipam_allocations")
|
||||
else:
|
||||
logger.info("✓ ipam_allocations multi-IP columns already exist")
|
||||
|
||||
|
||||
def migrate_setting_view_panel_only(conn):
|
||||
"""Migration 42: drop setting:view where it was only granting the profile page.
|
||||
|
||||
@@ -2247,6 +2289,14 @@ def run_all_migrations(engine, db_session=None):
|
||||
logger.warning(f"setting:view split migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
# Migration 43: several IP addresses per guest
|
||||
try:
|
||||
migrate_ipam_multi_ip(conn)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"IPAM multi-IP migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("✓ All migrations completed successfully")
|
||||
logger.info("=" * 50)
|
||||
|
||||
156
backend/tests/test_container_interfaces.py
Normal file
156
backend/tests/test_container_interfaces.py
Normal file
@@ -0,0 +1,156 @@
|
||||
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()
|
||||
|
||||
|
||||
def test_execute_script_sends_bytes_not_wide_chars():
|
||||
"""Скрипт с кириллицей не должен ронять Perl-обработчик Proxmox.
|
||||
|
||||
PVE кодирует input-data в base64 средствами Perl и падает с
|
||||
"Wide character in subroutine entry" на любой строке с не-ASCII.
|
||||
Отдаём UTF-8 байты, представленные как latin-1.
|
||||
"""
|
||||
px = MagicMock()
|
||||
agent = px.nodes.return_value.qemu.return_value.agent
|
||||
agent.exec.post.return_value = {"pid": 1}
|
||||
agent("exec-status").get.return_value = {"exited": 1, "exitcode": 0, "out-data": "ok"}
|
||||
|
||||
script = "#!/bin/sh\n# комментарий с кириллицей\necho ok\n"
|
||||
_client_with_mock(px).execute_script("prod", 102, script)
|
||||
|
||||
sent = agent.exec.post.call_args.kwargs["input-data"]
|
||||
assert all(ord(ch) < 256 for ch in sent), "в API уходят широкие символы"
|
||||
assert sent.encode("latin-1").decode("utf-8") == script
|
||||
|
||||
|
||||
def test_blocked_guest_exec_explains_how_to_fix():
|
||||
"""Образы RHEL-семейства запрещают guest-exec — ошибка должна это объяснять.
|
||||
|
||||
Proxmox отдаёт лишь "The command guest-exec has been disabled for this
|
||||
instance"; по такому тексту не догадаться, что чинить нужно
|
||||
/etc/sysconfig/qemu-ga внутри гостя.
|
||||
"""
|
||||
px = MagicMock()
|
||||
px.nodes.return_value.qemu.return_value.agent.exec.post.side_effect = RuntimeError(
|
||||
"500 Internal Server Error: QEMU guest agent command failed - "
|
||||
"The command guest-exec has been disabled for this instance"
|
||||
)
|
||||
|
||||
result = _client_with_mock(px).execute_script("prod", 103, "echo ok")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "/etc/sysconfig/qemu-ga" in result["error"]
|
||||
assert "FILTER_RPC_ARGS" in result["error"]
|
||||
|
||||
|
||||
def test_unrelated_agent_error_is_not_annotated():
|
||||
px = MagicMock()
|
||||
px.nodes.return_value.qemu.return_value.agent.exec.post.side_effect = RuntimeError(
|
||||
"500 QEMU guest agent is not running"
|
||||
)
|
||||
|
||||
result = _client_with_mock(px).execute_script("prod", 104, "echo ok")
|
||||
|
||||
assert result["error"] == "500 QEMU guest agent is not running"
|
||||
376
backend/tests/test_guest_ip_aliases_script.py
Normal file
376
backend/tests/test_guest_ip_aliases_script.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""Скрипт синхронизации 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
|
||||
"""
|
||||
|
||||
FAKE_SYSTEMCTL = """#!/bin/sh
|
||||
echo "$*" >> "$FAKE_LOG/systemctl.log"
|
||||
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),
|
||||
("systemctl", FAKE_SYSTEMCTL)):
|
||||
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", "systemctl"):
|
||||
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
|
||||
|
||||
|
||||
def test_script_is_ascii_only():
|
||||
"""Скрипт уезжает в гостя через API Proxmox, который спотыкается на
|
||||
не-ASCII, и его маркер попадает в системные конфиги — держим файл в ASCII."""
|
||||
raw = SCRIPT.read_bytes()
|
||||
non_ascii = [b for b in raw if b > 127]
|
||||
assert not non_ascii, f"в скрипте {len(non_ascii)} не-ASCII байт"
|
||||
|
||||
|
||||
# --- systemd-подстраховка ------------------------------------------------------
|
||||
|
||||
def test_systemd_unit_is_installed_alongside_native_stack(sandbox):
|
||||
"""Proxmox переписывает сетевой конфиг гостя при каждом старте, поэтому
|
||||
адреса восстанавливает ещё и oneshot-юнит — /etc/systemd он не трогает."""
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts", "etc/systemd/system")
|
||||
|
||||
result = sandbox["run"](addresses="10.10.10.90/24", tools=("ip", "systemctl"))
|
||||
|
||||
assert sandbox["persist_of"](result) == "sysconfig+systemd"
|
||||
unit = (root / "etc/systemd/system/pvemanager-aliases.service").read_text()
|
||||
assert "After=network-online.target" in unit
|
||||
assert "ExecStart=/usr/local/sbin/pvemanager-aliases" in unit
|
||||
helper = root / "usr/local/sbin/pvemanager-aliases"
|
||||
assert "ip addr add" in helper.read_text()
|
||||
assert "enable pvemanager-aliases.service" in sandbox["log"]("systemctl")
|
||||
|
||||
|
||||
def test_systemd_unit_alone_when_no_native_stack(sandbox):
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/systemd/system")
|
||||
|
||||
result = sandbox["run"](addresses="10.10.10.90/24", tools=("ip", "systemctl"))
|
||||
|
||||
assert sandbox["persist_of"](result) == "systemd"
|
||||
assert (root / "etc/systemd/system/pvemanager-aliases.service").exists()
|
||||
|
||||
|
||||
def test_systemd_unit_removed_when_last_address_goes(sandbox):
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/systemd/system")
|
||||
|
||||
sandbox["run"](addresses="10.10.10.90/24", tools=("ip", "systemctl"))
|
||||
result = sandbox["run"](addresses="", tools=("ip", "systemctl"))
|
||||
|
||||
assert sandbox["persist_of"](result) == "none"
|
||||
assert not (root / "etc/systemd/system/pvemanager-aliases.service").exists()
|
||||
assert not (root / "usr/local/sbin/pvemanager-aliases").exists()
|
||||
assert "disable pvemanager-aliases.service" in sandbox["log"]("systemctl")
|
||||
|
||||
|
||||
def test_systemd_unit_stays_while_another_interface_has_addresses(sandbox):
|
||||
"""Опустошили eth0, но на eth1 адрес остался — юнит нужен дальше."""
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/systemd/system")
|
||||
|
||||
sandbox["run"](addresses="10.10.10.90/24", iface="eth0", tools=("ip", "systemctl"))
|
||||
sandbox["run"](addresses="10.10.10.91/24", iface="eth1", tools=("ip", "systemctl"))
|
||||
sandbox["run"](addresses="", iface="eth0", tools=("ip", "systemctl"))
|
||||
|
||||
assert (root / "etc/systemd/system/pvemanager-aliases.service").exists()
|
||||
|
||||
|
||||
def test_without_systemd_persist_has_no_suffix(sandbox):
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts", "etc/systemd/system")
|
||||
|
||||
result = sandbox["run"](addresses="10.10.10.90/24", tools=("ip",))
|
||||
|
||||
assert sandbox["persist_of"](result) == "sysconfig"
|
||||
assert not (root / "etc/systemd/system/pvemanager-aliases.service").exists()
|
||||
240
backend/tests/test_guest_ip_service.py
Normal file
240
backend/tests/test_guest_ip_service.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Дополнительные адреса гостя: что уезжает в гостя и как трактуется ответ."""
|
||||
|
||||
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 == []
|
||||
|
||||
|
||||
def test_combined_persist_marker_is_understood(guest, monkeypatch):
|
||||
"""Скрипт сообщает составной способ закрепления: родной стек + systemd."""
|
||||
allocation = guest["add_alias"]("10.10.10.90")
|
||||
_patch(monkeypatch, script_engine.ExecResult(
|
||||
True, "PVEMANAGER_PERSIST=nm+systemd\nPVEMANAGER_OK=1\n", 0))
|
||||
|
||||
result = guest_ip_service.apply_address(guest["db"], allocation)
|
||||
|
||||
assert result.persist == "nm+systemd"
|
||||
assert result.status == guest_ip_service.APPLIED
|
||||
assert allocation.apply_status == guest_ip_service.APPLIED
|
||||
|
||||
|
||||
def test_plain_systemd_persist_is_applied(guest, monkeypatch):
|
||||
allocation = guest["add_alias"]("10.10.10.90")
|
||||
_patch(monkeypatch, script_engine.ExecResult(
|
||||
True, "PVEMANAGER_PERSIST=systemd\nPVEMANAGER_OK=1\n", 0))
|
||||
|
||||
assert guest_ip_service.apply_address(guest["db"], allocation).status == \
|
||||
guest_ip_service.APPLIED
|
||||
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
|
||||
302
backend/tests/test_ipam_multi_ip.py
Normal file
302
backend/tests/test_ipam_multi_ip.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""Несколько 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")
|
||||
|
||||
|
||||
def test_first_address_of_a_guest_becomes_primary_automatically(env):
|
||||
"""Мастер создания инстанса не передаёт is_primary — флаг ставится сам."""
|
||||
first, error = env["service"].allocate_ip(
|
||||
ip_address="10.10.10.60", network_id=env["network"].id, resource_type="lxc",
|
||||
resource_name="gitea", proxmox_server_id=env["server"].id, proxmox_vmid=109,
|
||||
proxmox_node="dev", allocated_by="wizard",
|
||||
)
|
||||
assert error is None and first.is_primary is True
|
||||
|
||||
second, error = env["service"].allocate_ip(
|
||||
ip_address="10.10.10.61", network_id=env["network"].id, resource_type="lxc",
|
||||
resource_name="gitea", proxmox_server_id=env["server"].id, proxmox_vmid=109,
|
||||
proxmox_node="dev", allocated_by="wizard",
|
||||
)
|
||||
assert error is None and second.is_primary is False
|
||||
|
||||
|
||||
def test_alias_never_claims_primary_by_itself(env):
|
||||
alias, error = env["service"].allocate_ip(
|
||||
ip_address="10.10.10.62", network_id=env["network"].id,
|
||||
proxmox_server_id=env["server"].id, proxmox_vmid=109, allocated_by="tester",
|
||||
assignment_kind="alias", target_interface="eth0",
|
||||
)
|
||||
assert error is None and alias.is_primary is False
|
||||
|
||||
|
||||
def test_explicit_flag_still_wins(env):
|
||||
env["allocate"]("10.10.10.63", primary=True, kind="primary")
|
||||
forced, error = env["service"].allocate_ip(
|
||||
ip_address="10.10.10.64", network_id=env["network"].id,
|
||||
proxmox_server_id=env["server"].id, proxmox_vmid=109, allocated_by="tester",
|
||||
is_primary=False,
|
||||
)
|
||||
assert error is None and forced.is_primary is False
|
||||
|
||||
|
||||
def test_reservation_without_a_guest_is_not_primary(env):
|
||||
"""Ручная бронь без привязки к гостю основной быть не может."""
|
||||
manual, error = env["service"].allocate_ip(
|
||||
ip_address="10.10.10.65", network_id=env["network"].id,
|
||||
resource_name="reserved by hand", allocated_by="admin",
|
||||
)
|
||||
assert error is None and manual.is_primary is False
|
||||
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"] == ""
|
||||
@@ -77,7 +77,7 @@ services:
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
start_period: 120s
|
||||
# Ограничение ресурсов для стабильности
|
||||
deploy:
|
||||
resources:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useBackupStorages, useProxmoxBackupJobs,
|
||||
useCreateStorage, useUpdateStorage, useDeleteStorage,
|
||||
useCreateProxmoxJob, useUpdateProxmoxJob, useDeleteProxmoxJob,
|
||||
useScanNfsExports,
|
||||
type ProxmoxJobInput,
|
||||
} from '@/hooks/use-backups';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
@@ -88,6 +89,8 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
const { t } = useTranslation();
|
||||
const createStorage = useCreateStorage(serverId);
|
||||
const updateStorage = useUpdateStorage(serverId);
|
||||
const scanNfs = useScanNfsExports(serverId);
|
||||
const [nfsExports, setNfsExports] = useState<string[]>([]);
|
||||
const [form, setForm] = useState<StorageForm>(() => editing ? {
|
||||
...emptyForm,
|
||||
storage: editing.storage,
|
||||
@@ -102,6 +105,20 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
} : emptyForm);
|
||||
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 = () => {
|
||||
if (!editing && !form.storage.trim()) { toast.error(t('storages.id_required')); return; }
|
||||
const done = (msg: string) => { toast.success(msg); onOpenChange(false); };
|
||||
@@ -185,8 +202,27 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
)}
|
||||
{!editing && form.type === 'nfs' && (
|
||||
<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><Label>{t('storages.export')}</Label><Input value={form.export} onChange={e => set('export', e.target.value)} className="mt-1" placeholder="/exports/backup" /></div>
|
||||
<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>
|
||||
)}
|
||||
{!editing && form.type === 'cifs' && (
|
||||
|
||||
@@ -73,6 +73,14 @@ interface VNCData {
|
||||
auth_ticket?: string;
|
||||
}
|
||||
|
||||
// Сессия консоли переживает один проход эффекта: connect* асинхронны, а React
|
||||
// StrictMode в dev монтирует эффект дважды. Без флага отмены вторая пара
|
||||
// xterm/RFB ложится в тот же контейнер поверх первой — «осиротевший» терминал
|
||||
// остаётся в DOM и занимает видимую область, из-за чего экран выглядит пустым.
|
||||
interface SessionGuard {
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
interface RFBHandle {
|
||||
disconnect: () => void;
|
||||
sendCtrlAltDel: () => void;
|
||||
@@ -108,12 +116,37 @@ export default function ConsolePage() {
|
||||
const vid = Number(vmid);
|
||||
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) ====================
|
||||
const connectVNC = useCallback(async () => {
|
||||
const connectVNC = useCallback(async (guard: SessionGuard) => {
|
||||
try {
|
||||
const data = await apiClient.get<VNCData>(
|
||||
`/proxmox/api/${sid}/vm/${vid}/vnc?node=${node}`
|
||||
);
|
||||
if (guard.cancelled) return;
|
||||
|
||||
// Dynamic import noVNC. The package ships CJS, so depending on bundler
|
||||
// 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}`;
|
||||
|
||||
if (!containerRef.current) return;
|
||||
if (guard.cancelled || !containerRef.current) return;
|
||||
|
||||
// Pass URL string to RFB, NOT an open WebSocket
|
||||
const rfb = new RFB(containerRef.current, wsUrl, {
|
||||
@@ -160,10 +193,12 @@ export default function ConsolePage() {
|
||||
rfb.resizeSession = false;
|
||||
|
||||
rfb.addEventListener('connect', () => {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('connected');
|
||||
});
|
||||
|
||||
rfb.addEventListener('disconnect', (e: CustomEvent) => {
|
||||
if (guard.cancelled) return;
|
||||
if (e.detail?.clean) {
|
||||
setStatus('error');
|
||||
setErrorMsg('Connection closed');
|
||||
@@ -176,6 +211,7 @@ export default function ConsolePage() {
|
||||
rfbRef.current = rfb as RFBHandle;
|
||||
setScaleToFit(true);
|
||||
} catch (err) {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
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 + WebSocket. wsPath — путь до нужного ws-эндпоинта Proxmox-прокси.
|
||||
const setupXtermSession = useCallback(async (wsPath: string) => {
|
||||
const setupXtermSession = useCallback(async (wsPath: string, guard: SessionGuard) => {
|
||||
// Dynamic import xterm.js
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
@@ -191,7 +227,7 @@ export default function ConsolePage() {
|
||||
import('@xterm/addon-web-links'),
|
||||
]);
|
||||
|
||||
if (!containerRef.current) return;
|
||||
if (guard.cancelled || !containerRef.current) return;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
@@ -219,9 +255,12 @@ export default function ConsolePage() {
|
||||
const wsUrl = `${wsProto}//${window.location.host}${wsPath}${tokenParam}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (guard.cancelled) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
setStatus('connected');
|
||||
const { cols, rows } = terminal;
|
||||
ws.send(`1:${cols}:${rows}:`);
|
||||
@@ -231,11 +270,20 @@ export default function ConsolePage() {
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (guard.cancelled) return;
|
||||
if (event.data instanceof ArrayBuffer) terminal.write(new Uint8Array(event.data));
|
||||
else terminal.write(event.data);
|
||||
};
|
||||
ws.onclose = () => { setStatus('error'); setErrorMsg('Terminal connection closed'); };
|
||||
ws.onerror = () => { setStatus('error'); setErrorMsg('Terminal connection error'); };
|
||||
ws.onclose = () => {
|
||||
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) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
@@ -249,62 +297,75 @@ export default function ConsolePage() {
|
||||
|
||||
const handleResize = () => fitAddon.fit();
|
||||
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) };
|
||||
}, []);
|
||||
|
||||
// ==================== Terminal Console (LXC) ====================
|
||||
const connectTerminal = useCallback(async () => {
|
||||
const connectTerminal = useCallback(async (guard: SessionGuard) => {
|
||||
try {
|
||||
const data = await apiClient.get<VNCData>(
|
||||
`/proxmox/api/${sid}/container/${vid}/terminal?node=${node}`
|
||||
);
|
||||
await setupXtermSession(`/proxmox/ws/terminal/${sid}/${data.node}/${vid}`);
|
||||
// Termproxy-сессию создаёт сам ws-эндпоинт. Отдельный REST-вызов
|
||||
// (GET .../container/{vmid}/terminal) поднимал бы на ноде вторую
|
||||
// termproxy-сессию, к которой никто не подключается: Proxmox ждёт клиента
|
||||
// ~10 секунд и завершает задачу с «failed waiting for client: timed out».
|
||||
await setupXtermSession(`/proxmox/ws/terminal/${sid}/${node}/${vid}`, guard);
|
||||
} catch (err) {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [sid, vid, node, setupXtermSession]);
|
||||
|
||||
// ==================== Serial Console (QEMU) ====================
|
||||
const connectSerial = useCallback(async () => {
|
||||
const connectSerial = useCallback(async (guard: SessionGuard) => {
|
||||
try {
|
||||
// Убедиться, что у VM есть serial0 (добавит при отсутствии)
|
||||
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) {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [sid, vid, node, setupXtermSession]);
|
||||
|
||||
// ==================== 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(() => {
|
||||
if (isSerial) {
|
||||
connectSerial();
|
||||
} else if (type === 'qemu') {
|
||||
connectVNC();
|
||||
} else {
|
||||
connectTerminal();
|
||||
}
|
||||
startSession();
|
||||
|
||||
return () => {
|
||||
// Cleanup
|
||||
if (rfbRef.current) {
|
||||
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();
|
||||
}
|
||||
if (guardRef.current) guardRef.current.cancelled = true;
|
||||
cleanupSession();
|
||||
};
|
||||
}, [type, isSerial, connectVNC, connectTerminal, connectSerial]);
|
||||
}, [startSession, cleanupSession]);
|
||||
|
||||
// ==================== Fullscreen ====================
|
||||
const toggleFullscreen = () => {
|
||||
@@ -574,13 +635,7 @@ export default function ConsolePage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatus('connecting');
|
||||
setErrorMsg('');
|
||||
if (isSerial) connectSerial();
|
||||
else if (type === 'qemu') connectVNC();
|
||||
else connectTerminal();
|
||||
}}
|
||||
onClick={startSession}
|
||||
>
|
||||
{t('console.reconnect')}
|
||||
</Button>
|
||||
|
||||
@@ -25,10 +25,30 @@ export default function NodeShellPage() {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const keepaliveRef = useRef<ReturnType<typeof setInterval> | 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 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 {
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
@@ -36,7 +56,7 @@ export default function NodeShellPage() {
|
||||
import('@xterm/addon-web-links'),
|
||||
]);
|
||||
|
||||
if (!containerRef.current) return;
|
||||
if (guard.cancelled || !containerRef.current) return;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
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 ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (guard.cancelled) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
setStatus('connected');
|
||||
const { cols, rows } = terminal;
|
||||
ws.send(`1:${cols}:${rows}:`);
|
||||
@@ -77,6 +100,7 @@ export default function NodeShellPage() {
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (guard.cancelled) return;
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
terminal.write(new Uint8Array(event.data));
|
||||
} else {
|
||||
@@ -85,10 +109,12 @@ export default function NodeShellPage() {
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(t('console.terminal_closed', 'Соединение с терминалом закрыто'));
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(t('console.terminal_error', 'Ошибка соединения с терминалом'));
|
||||
};
|
||||
@@ -106,24 +132,43 @@ export default function NodeShellPage() {
|
||||
|
||||
const handleResize = () => fitAddon.fit();
|
||||
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) };
|
||||
} catch (err) {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [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(() => {
|
||||
connect();
|
||||
startSession();
|
||||
return () => {
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
if (keepaliveRef.current) clearInterval(keepaliveRef.current);
|
||||
if (termRef.current) {
|
||||
termRef.current.cleanup();
|
||||
termRef.current.terminal.dispose();
|
||||
}
|
||||
if (guardRef.current) guardRef.current.cancelled = true;
|
||||
cleanupSession();
|
||||
};
|
||||
}, [connect]);
|
||||
}, [startSession, cleanupSession]);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
@@ -182,11 +227,7 @@ export default function NodeShellPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatus('connecting');
|
||||
setErrorMsg('');
|
||||
connect();
|
||||
}}
|
||||
onClick={startSession}
|
||||
>
|
||||
{t('console.reconnect', 'Переподключиться')}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, Download, HardDriveDownload, Package, ChevronDown, Disc } from 'lucide-react';
|
||||
import { Search, Download } from 'lucide-react';
|
||||
import { OsLogo } from '@/features/templates/OsLogo';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -22,7 +22,9 @@ import DownloadIsoDialog, { type IsoSource } from './DownloadIsoDialog';
|
||||
import MirrorsManager from './MirrorsManager';
|
||||
import type { CatalogImage } from '@/types';
|
||||
|
||||
export default function ImagesPage() {
|
||||
export type ImageCatalogSection = 'vm-images' | 'lxc' | 'iso' | 'repositories';
|
||||
|
||||
export default function ImageCatalogPanel({ section }: { section: ImageCatalogSection }) {
|
||||
const { t } = useTranslation();
|
||||
const [serverId, setServerId] = useState<number | null>(null);
|
||||
const [node, setNode] = useState<string>('');
|
||||
@@ -31,16 +33,9 @@ export default function ImagesPage() {
|
||||
const [archFilter, setArchFilter] = useState<string>('amd64');
|
||||
const [selected, setSelected] = useState<SelectedImage | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [cloudOpen, setCloudOpen] = useState(() => localStorage.getItem('images-cloud-open') !== '0');
|
||||
const [lxcOpen, setLxcOpen] = useState(() => localStorage.getItem('images-lxc-open') !== '0');
|
||||
const [isoOpen, setIsoOpen] = useState(() => localStorage.getItem('images-iso-open') !== '0');
|
||||
const [isoSource, setIsoSource] = useState<IsoSource | null>(null);
|
||||
const [isoDialogOpen, setIsoDialogOpen] = useState(false);
|
||||
|
||||
const toggleCloud = () => setCloudOpen((v) => { localStorage.setItem('images-cloud-open', v ? '0' : '1'); return !v; });
|
||||
const toggleLxc = () => setLxcOpen((v) => { localStorage.setItem('images-lxc-open', v ? '0' : '1'); return !v; });
|
||||
const toggleIso = () => setIsoOpen((v) => { localStorage.setItem('images-iso-open', v ? '0' : '1'); return !v; });
|
||||
|
||||
const { data: servers = [] } = useServers();
|
||||
const { data: profile } = useProfile();
|
||||
const isAdmin = !!profile?.is_admin;
|
||||
@@ -97,7 +92,7 @@ export default function ImagesPage() {
|
||||
|
||||
const lxcTemplates = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return lxcRepo.filter((t) => !q || `${t.template} ${t.os ?? ''} ${t.headline ?? ''}`.toLowerCase().includes(q));
|
||||
return lxcRepo.filter((tpl) => !q || `${tpl.template} ${tpl.os ?? ''} ${tpl.headline ?? ''}`.toLowerCase().includes(q));
|
||||
}, [lxcRepo, search]);
|
||||
|
||||
const openDownload = (img: SelectedImage) => {
|
||||
@@ -106,53 +101,44 @@ export default function ImagesPage() {
|
||||
};
|
||||
|
||||
const canDownload = serverId != null && !!node;
|
||||
const needsContext = section === 'vm-images' || section === 'lxc' || section === 'iso';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDriveDownload className="h-6 w-6" />
|
||||
<h1 className="text-2xl font-bold">{t('images.title')}</h1>
|
||||
<div className="space-y-4">
|
||||
{needsContext && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Select value={serverId != null ? String(serverId) : ''} onValueChange={(v) => { if (v !== null) { setServerId(Number(v)); setNode(''); } }}>
|
||||
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('images.server')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={node} onValueChange={(v) => { if (v) setNode(v); }}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('images.node')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodes.length === 0 && <SelectItem value="__empty__" disabled>{t('images.no_nodes')}</SelectItem>}
|
||||
{nodes.map((n) => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input className="pl-9" placeholder={t('images.search')} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
{section === 'vm-images' && (
|
||||
<Select value={archFilter || '__all__'} onValueChange={(v) => { if (v !== null) setArchFilter(v === '__all__' ? '' : v); }}>
|
||||
<SelectTrigger className="w-[150px]"><SelectValue placeholder={t('images.arch')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t('images.arch_all')}</SelectItem>
|
||||
<SelectItem value="amd64">amd64</SelectItem>
|
||||
<SelectItem value="arm64">arm64</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контекст: сервер + нода */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Select value={serverId != null ? String(serverId) : ''} onValueChange={(v) => { if (v !== null) { setServerId(Number(v)); setNode(''); } }}>
|
||||
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('images.server')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={node} onValueChange={(v) => { if (v) setNode(v); }}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('images.node')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodes.length === 0 && <SelectItem value="__empty__" disabled>{t('images.no_nodes')}</SelectItem>}
|
||||
{nodes.map((n) => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input className="pl-9" placeholder={t('images.search')} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<Select value={archFilter || '__all__'} onValueChange={(v) => { if (v !== null) setArchFilter(v === '__all__' ? '' : v); }}>
|
||||
<SelectTrigger className="w-[150px]"><SelectValue placeholder={t('images.arch')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t('images.arch_all')}</SelectItem>
|
||||
<SelectItem value="amd64">amd64</SelectItem>
|
||||
<SelectItem value="arm64">arm64</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cloud-образы (qcow2 → шаблон ВМ) */}
|
||||
<section className="space-y-3">
|
||||
<button type="button" onClick={toggleCloud}
|
||||
className="flex w-full items-center gap-2 text-lg font-semibold">
|
||||
<ChevronDown className={`h-5 w-5 transition-transform ${cloudOpen ? '' : '-rotate-90'}`} />
|
||||
<Package className="h-5 w-5" /> {t('images.cloud_section')}
|
||||
</button>
|
||||
{cloudOpen && (
|
||||
{section === 'vm-images' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{cloudImages.map((img) => (
|
||||
<Card key={img.id}>
|
||||
@@ -176,23 +162,18 @@ export default function ImagesPage() {
|
||||
<div className="text-sm text-muted-foreground col-span-full">{t('images.no_images_filter')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ISO-образы (установочные) */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button type="button" onClick={toggleIso}
|
||||
className="flex items-center gap-2 text-lg font-semibold">
|
||||
<ChevronDown className={`h-5 w-5 transition-transform ${isoOpen ? '' : '-rotate-90'}`} />
|
||||
<Disc className="h-5 w-5" /> {t('images.iso_section')}
|
||||
</button>
|
||||
<Button size="sm" variant="outline" disabled={!canDownload}
|
||||
onClick={() => openIsoDownload(null)}>
|
||||
<Download className="mr-2 h-4 w-4" /> {t('images.download_by_url')}
|
||||
</Button>
|
||||
</div>
|
||||
{isoOpen && (<>
|
||||
{section === 'iso' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" variant="outline" disabled={!canDownload}
|
||||
onClick={() => openIsoDownload(null)}>
|
||||
<Download className="mr-2 h-4 w-4" /> {t('images.download_by_url')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!canDownload && (
|
||||
<div className="text-sm text-muted-foreground">{t('images.select_server_node')}</div>
|
||||
)}
|
||||
@@ -243,41 +224,43 @@ export default function ImagesPage() {
|
||||
{t('images.no_iso_links')}
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LXC-шаблоны (репозиторий Proxmox) */}
|
||||
<section className="space-y-3">
|
||||
<button type="button" onClick={toggleLxc}
|
||||
className="flex w-full items-center gap-2 text-lg font-semibold">
|
||||
<ChevronDown className={`h-5 w-5 transition-transform ${lxcOpen ? '' : '-rotate-90'}`} />
|
||||
<Package className="h-5 w-5" /> {t('images.lxc_section')}
|
||||
</button>
|
||||
{lxcOpen && (<>
|
||||
{!canDownload && (
|
||||
<div className="text-sm text-muted-foreground">{t('images.select_server_node_list')}</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{lxcTemplates.map((t) => (
|
||||
<Card key={t.template}>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<OsLogo name={t.os || t.template} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate" title={t.template}>{t.headline || t.template}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{t.template}</div>
|
||||
</div>
|
||||
<Button size="sm" disabled={!canDownload}
|
||||
onClick={() => openDownload({ kind: 'vztmpl', name: t.template, template: t.template })}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{section === 'lxc' && (
|
||||
<div className="space-y-3">
|
||||
{!canDownload && (
|
||||
<div className="text-sm text-muted-foreground">{t('images.select_server_node_list')}</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{lxcTemplates.map((tpl) => (
|
||||
<Card key={tpl.template}>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<OsLogo name={tpl.os || tpl.template} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate" title={tpl.template}>{tpl.headline || tpl.template}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{tpl.template}</div>
|
||||
</div>
|
||||
<Button size="sm" disabled={!canDownload}
|
||||
onClick={() => openDownload({ kind: 'vztmpl', name: tpl.template, template: tpl.template })}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isAdmin && <MirrorsManager />}
|
||||
{/* Репозитории / зеркала */}
|
||||
{section === 'repositories' && (
|
||||
isAdmin ? (
|
||||
<MirrorsManager />
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">{t('common.access_denied')}</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<DownloadImageDialog
|
||||
open={dialogOpen}
|
||||
235
frontend/src/features/instances/AddGuestIPDialog.tsx
Normal file
235
frontend/src/features/instances/AddGuestIPDialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useIPAMNetworks, useIPAMPools, useAddGuestAddress } from '@/hooks/use-ipam';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const IP_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
/** Имена интерфейсов гостя, среди которых можно выбрать целевой */
|
||||
interfaces: string[];
|
||||
}
|
||||
|
||||
export default function AddGuestIPDialog({ open, onClose, serverId, vmid, interfaces }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Диалог монтируется только на время показа (см. NetworkingTab), поэтому
|
||||
// состояние достаточно задать начальными значениями — сбрасывать эффектом
|
||||
// при каждом открытии не нужно.
|
||||
const [networkId, setNetworkId] = useState('');
|
||||
const [poolId, setPoolId] = useState('');
|
||||
const [mode, setMode] = useState<'auto' | 'manual'>('auto');
|
||||
const [ip, setIp] = useState('');
|
||||
const [iface, setIface] = useState(interfaces[0] ?? '');
|
||||
const [makePrimary, setMakePrimary] = useState(false);
|
||||
|
||||
const { data: networks } = useIPAMNetworks();
|
||||
const { data: pools } = useIPAMPools(networkId ? Number(networkId) : undefined);
|
||||
const addAddress = useAddGuestAddress(serverId, vmid);
|
||||
|
||||
const activeNetworks = useMemo(
|
||||
() => (networks ?? []).filter((n) => n.is_active !== false),
|
||||
[networks],
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
if (!networkId) {
|
||||
toast.error(t('ipam.err_network_required', 'Выберите сеть'));
|
||||
return;
|
||||
}
|
||||
if (mode === 'manual' && !IP_RE.test(ip)) {
|
||||
toast.error(t('ipam.err_bad_ip', 'Некорректный IP-адрес'));
|
||||
return;
|
||||
}
|
||||
|
||||
addAddress.mutate(
|
||||
{
|
||||
network_id: Number(networkId),
|
||||
pool_id: poolId ? Number(poolId) : undefined,
|
||||
ip_address: mode === 'manual' ? ip : undefined,
|
||||
target_interface: iface || undefined,
|
||||
make_primary: makePrimary,
|
||||
},
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
const address = res.address?.ip_address ?? '';
|
||||
if (res.applied) {
|
||||
if (res.apply_status === 'runtime_only') {
|
||||
toast.warning(
|
||||
t('ipam.address_runtime_only', {
|
||||
defaultValue: '{{ip}} поднят, но не закреплён: стек сети гостя не распознан',
|
||||
ip: address,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
t('ipam.address_added', { defaultValue: 'Адрес {{ip}} назначен', ip: address }),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Адрес всё равно зарезервирован — применить можно позже
|
||||
toast.warning(
|
||||
t('ipam.address_reserved_not_applied', {
|
||||
defaultValue: '{{ip}} зарезервирован, но не применён: {{error}}',
|
||||
ip: address,
|
||||
error: res.error ?? '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
{t('ipam.add_address_title', 'Добавить IP-адрес')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'ipam.add_address_description',
|
||||
'Адрес выделяется в IPAM и навешивается на интерфейс гостя дополнительным (alias).',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('ipam.network', 'Сеть')}</Label>
|
||||
<Select value={networkId} onValueChange={(v) => { setNetworkId(v ?? ''); setPoolId(''); }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('ipam.select_network', 'Выберите сеть')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeNetworks.map((n) => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
{n.name}
|
||||
<span className="text-xs text-muted-foreground">{n.network}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('ipam.pool', 'Пул')}</Label>
|
||||
<Select value={poolId} onValueChange={(v) => setPoolId(v ?? '')} disabled={!networkId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('ipam.any_pool', 'Любой')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(pools ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={String(p.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
{p.name}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{p.range_start} – {p.range_end}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('ipam.address_mode', 'Адрес')}</Label>
|
||||
<Select value={mode} onValueChange={(v) => setMode(v as 'auto' | 'manual')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('ipam.next_free', 'Следующий свободный')}</SelectItem>
|
||||
<SelectItem value="manual">{t('ipam.manual_ip', 'Указать вручную')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{mode === 'manual' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="guest-ip">IP</Label>
|
||||
<Input
|
||||
id="guest-ip"
|
||||
value={ip}
|
||||
onChange={(e) => setIp(e.target.value)}
|
||||
placeholder="10.10.10.90"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('ipam.target_interface', 'Интерфейс гостя')}</Label>
|
||||
{interfaces.length > 0 ? (
|
||||
<Select value={iface} onValueChange={(v) => setIface(v ?? '')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="eth0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{interfaces.map((name) => (
|
||||
<SelectItem key={name} value={name}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={iface}
|
||||
onChange={(e) => setIface(e.target.value)}
|
||||
placeholder="eth0"
|
||||
className="font-mono"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={makePrimary} onChange={(e) => setMakePrimary(e.target.checked)} />
|
||||
{t('ipam.make_primary', 'Сделать основным адресом')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||
<Button onClick={submit} disabled={addAddress.isPending || !networkId}>
|
||||
{t('ipam.add_address_submit', 'Выдать адрес')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1031,16 +1031,33 @@ export default function InstancesPage() {
|
||||
},
|
||||
{
|
||||
id: 'ip',
|
||||
accessorKey: 'ip_address',
|
||||
// Поиск идёт по всем адресам гостя, а показывается основной
|
||||
accessorFn: (vm) => [vm.ip_address, ...(vm.ips ?? []).map((a) => a.ip)]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
filterFn: 'includesString',
|
||||
meta: { filter: 'text' },
|
||||
header: 'IP',
|
||||
cell: ({ getValue }) => {
|
||||
const ip = getValue<string>();
|
||||
return ip ? (
|
||||
<span className="font-mono text-xs">{ip}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
const primary = vm.ip_address;
|
||||
const extra = (vm.ips ?? []).filter((a) => a.ip !== primary);
|
||||
if (!primary) {
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-xs">{primary}</span>
|
||||
{extra.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-2xs"
|
||||
title={extra.map((a) => a.ip).join(', ')}
|
||||
>
|
||||
{t('ipam.extra_addresses', { defaultValue: 'ещё {{count}}', count: extra.length })}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
size: 130,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe, Wifi, Plus, Trash2 } from 'lucide-react';
|
||||
import { Globe, Wifi, Plus, Trash2, Star, RefreshCw } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -13,9 +13,17 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useVMInterfaces, useVMConfig, useVMStatus, useUpdateConfig } from '@/hooks/use-instances';
|
||||
import {
|
||||
useGuestAddresses,
|
||||
useApplyGuestAddress,
|
||||
useSetPrimaryGuestAddress,
|
||||
useRemoveGuestAddress,
|
||||
} from '@/hooks/use-ipam';
|
||||
import type { GuestAddress } from '@/types';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { toast } from 'sonner';
|
||||
import AddNetworkInterfaceDialog from '../AddNetworkInterfaceDialog';
|
||||
import AddGuestIPDialog from '../AddGuestIPDialog';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
@@ -41,6 +49,23 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
: [];
|
||||
|
||||
// Имена интерфейсов гостя для выбора цели alias-адреса: сперва то, что
|
||||
// реально видно в госте, иначе имена из конфига (LXC пишет name=ethN).
|
||||
const guestInterfaces = (() => {
|
||||
const live = (ifaces?.interfaces ?? [])
|
||||
.map((i) => i.name)
|
||||
.filter((name): name is string => !!name && name !== 'lo');
|
||||
if (live.length > 0) return live;
|
||||
return netDevices
|
||||
.map(([, value]) =>
|
||||
String(value)
|
||||
.split(',')
|
||||
.find((part) => part.startsWith('name='))
|
||||
?.slice(5),
|
||||
)
|
||||
.filter((name): name is string => !!name);
|
||||
})();
|
||||
|
||||
const removeNic = () => {
|
||||
if (!deleteTarget) return;
|
||||
updateConfig.mutate(
|
||||
@@ -73,6 +98,9 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* IPAM addresses of this guest */}
|
||||
<GuestAddressesCard serverId={serverId} vmid={vmid} interfaces={guestInterfaces} />
|
||||
|
||||
{/* Configured network devices */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
@@ -199,3 +227,213 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuestAddressesCard({ serverId, vmid, interfaces }: {
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
interfaces: string[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useGuestAddresses(serverId, vmid);
|
||||
const applyAddress = useApplyGuestAddress(serverId, vmid);
|
||||
const setPrimary = useSetPrimaryGuestAddress(serverId, vmid);
|
||||
const removeAddress = useRemoveGuestAddress(serverId, vmid);
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [releaseTarget, setReleaseTarget] = useState<GuestAddress | null>(null);
|
||||
|
||||
const addresses = data?.addresses ?? [];
|
||||
|
||||
// Статус применения alias-адреса: applied — закреплён в конфиге гостя,
|
||||
// runtime_only — поднят, но не переживёт перезагрузку, pending/failed —
|
||||
// адрес зарезервирован в IPAM, но на гостя не попал.
|
||||
const statusBadge = (address: GuestAddress) => {
|
||||
if (address.assignment_kind !== 'alias') {
|
||||
return <Badge variant="secondary" className="text-xs">{t('ipam.from_nic_config', 'из конфига NIC')}</Badge>;
|
||||
}
|
||||
switch (address.apply_status) {
|
||||
case 'applied':
|
||||
return <Badge variant="secondary" className="text-xs">{t('ipam.applied', 'применён')}</Badge>;
|
||||
case 'runtime_only':
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
title={t('ipam.runtime_only_hint', 'Адрес поднят, но стек сети гостя не распознан — после перезагрузки исчезнет')}
|
||||
>
|
||||
{t('ipam.runtime_only', 'до перезагрузки')}
|
||||
</Badge>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs" title={address.apply_error ?? undefined}>
|
||||
{t('ipam.not_applied', 'не применён')}
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs" title={address.apply_error ?? undefined}>
|
||||
{t('common.error', 'Ошибка')}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge variant="outline" className="text-xs">{t('ipam.discovered', 'обнаружен')}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const release = () => {
|
||||
if (!releaseTarget) return;
|
||||
removeAddress.mutate(releaseTarget.id, {
|
||||
onSuccess: (res) => {
|
||||
if (res.removed_from_guest) {
|
||||
toast.success(t('ipam.address_released', { defaultValue: 'Адрес {{ip}} освобождён', ip: res.released }));
|
||||
} else {
|
||||
toast.warning(
|
||||
t('ipam.address_released_not_removed', {
|
||||
defaultValue: '{{ip}} освобождён в IPAM, но снять его с гостя не удалось: {{error}}',
|
||||
ip: res.released,
|
||||
error: res.error ?? '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
setReleaseTarget(null);
|
||||
},
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
{t('ipam.addresses', 'IP-адреса')}
|
||||
{addresses.length > 0 && <Badge variant="secondary">{addresses.length}</Badge>}
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowAdd(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('ipam.add_address', 'Добавить IP')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{isLoading ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка...')}</p>
|
||||
) : addresses.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t('ipam.no_addresses', 'Адресов в IPAM нет')}
|
||||
</p>
|
||||
) : (
|
||||
addresses.map((address) => (
|
||||
<div key={address.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm">
|
||||
{address.ip_address}
|
||||
{address.prefix ? `/${address.prefix}` : ''}
|
||||
</span>
|
||||
{address.is_primary && (
|
||||
<Badge className="text-xs">{t('ipam.primary', 'основной')}</Badge>
|
||||
)}
|
||||
{statusBadge(address)}
|
||||
{address.target_interface && (
|
||||
<Badge variant="outline" className="text-xs">{address.target_interface}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{address.assignment_kind === 'alias' && address.apply_status !== 'applied' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={applyAddress.isPending}
|
||||
title={t('ipam.apply', 'Применить')}
|
||||
onClick={() =>
|
||||
applyAddress.mutate(address.id, {
|
||||
onSuccess: (res) =>
|
||||
res.applied
|
||||
? toast.success(t('ipam.applied', 'применён'))
|
||||
: toast.error(res.error ?? t('common.error', 'Ошибка')),
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{!address.is_primary && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={setPrimary.isPending}
|
||||
title={t('ipam.make_primary', 'Сделать основным адресом')}
|
||||
onClick={() =>
|
||||
setPrimary.mutate(address.id, {
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
disabled={address.assignment_kind !== 'alias'}
|
||||
title={
|
||||
address.assignment_kind === 'alias'
|
||||
? t('ipam.release', 'Освободить')
|
||||
: t('ipam.primary_managed_by_nic', 'Основной адрес задаётся конфигом интерфейса')
|
||||
}
|
||||
onClick={() => setReleaseTarget(address)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{address.apply_error && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{address.apply_error}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{showAdd && (
|
||||
<AddGuestIPDialog
|
||||
open
|
||||
onClose={() => setShowAdd(false)}
|
||||
serverId={serverId}
|
||||
vmid={vmid}
|
||||
interfaces={interfaces}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={!!releaseTarget} onOpenChange={(o) => !o && setReleaseTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('ipam.release_title', 'Освободить адрес?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('ipam.release_description', {
|
||||
defaultValue:
|
||||
'Адрес {{ip}} будет снят с интерфейса гостя и освобождён в IPAM.',
|
||||
ip: releaseTarget?.ip_address ?? '',
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReleaseTarget(null)}>
|
||||
{t('common.cancel', 'Отмена')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={release} disabled={removeAddress.isPending}>
|
||||
{t('ipam.release', 'Освободить')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,9 @@ export default function IPAMAllocationsPage() {
|
||||
a.ip_address?.toLowerCase().includes(s) ||
|
||||
a.resource_name?.toLowerCase().includes(s) ||
|
||||
a.mac_address?.toLowerCase().includes(s) ||
|
||||
a.description?.toLowerCase().includes(s)
|
||||
a.description?.toLowerCase().includes(s) ||
|
||||
// поиск по VMID, чтобы собрать все адреса одного гостя
|
||||
String(a.proxmox_vmid ?? '').includes(s)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -108,12 +110,25 @@ export default function IPAMAllocationsPage() {
|
||||
<TableBody>
|
||||
{filtered.map(a => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-mono">{a.ip_address}</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{a.ip_address}
|
||||
{/* У гостя может быть несколько адресов — помечаем основной */}
|
||||
{a.is_primary && (
|
||||
<Badge variant="secondary" className="text-2xs">{t('ipam.primary')}</Badge>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{a.network_name || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{a.resource_name || '—'}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{a.resource_name || '—'}
|
||||
{a.proxmox_vmid != null && (
|
||||
<span className="ml-1 text-muted-foreground">#{a.proxmox_vmid}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">{a.description || '—'}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -235,7 +235,20 @@ function UnlinkedCard() {
|
||||
|
||||
const handleLink = () => {
|
||||
link.mutate(undefined, {
|
||||
onSuccess: (r) => toast.success(`${t('ipam.linked')}: ${r.linked?.length ?? 0}`),
|
||||
onSuccess: (r) => {
|
||||
toast.success(`${t('ipam.linked')}: ${r.linked?.length ?? 0}`);
|
||||
// Пропуски (дубль IP, недоступный сервер, адрес вне сетей IPAM) иначе
|
||||
// видны только в логах бэкенда — показываем причины первых записей.
|
||||
const skipped = r.not_found ?? [];
|
||||
if (skipped.length > 0) {
|
||||
toast.warning(t('ipam.link_skipped', { count: skipped.length }), {
|
||||
description: skipped
|
||||
.slice(0, 3)
|
||||
.map((s) => `${s.resource_name}: ${s.reason}`)
|
||||
.join('\n'),
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
364
frontend/src/features/templates/OsTemplatesTab.tsx
Normal file
364
frontend/src/features/templates/OsTemplatesTab.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type ColumnFiltersState,
|
||||
} from '@tanstack/react-table';
|
||||
import { Monitor, Search, Download, Trash2, Loader2, Rocket, PackagePlus, Pencil, ArrowUpDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { OsLogo } from './OsLogo';
|
||||
import { DeployDialog, DownloadCTDialog, EditTemplateDialog } from './TemplateDialogs';
|
||||
import type { OSTemplate } from '@/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { ColumnFilter, multiSelectFilter } from '@/components/shared/column-filter';
|
||||
import { useTemplates, useAutoImportTemplates, useDeleteTemplate } from '@/hooks/use-templates';
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
function SortHeader({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Button variant="ghost" size="sm" className="-ml-3 h-8" onClick={onClick}>
|
||||
{label}
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OsTemplatesTab() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [importServerId, setImportServerId] = useState<string>('');
|
||||
const [deployTpl, setDeployTpl] = useState<OSTemplate | null>(null);
|
||||
const [editTpl, setEditTpl] = useState<OSTemplate | null>(null);
|
||||
const [downloadOpen, setDownloadOpen] = useState(false);
|
||||
|
||||
const { data: templates = [], isLoading } = useTemplates();
|
||||
const { data: servers = [] } = useServers();
|
||||
const autoImport = useAutoImportTemplates();
|
||||
const deleteTpl = useDeleteTemplate();
|
||||
|
||||
const handleImport = () => {
|
||||
if (!importServerId) return;
|
||||
autoImport.mutate(Number(importServerId), {
|
||||
onSuccess: (res) => {
|
||||
const count = res.imported_count ?? 0;
|
||||
toast.success(
|
||||
count > 0
|
||||
? t('templates.imported_count', { count })
|
||||
: t('templates.imported_none'),
|
||||
);
|
||||
setImportDialogOpen(false);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<OSTemplate>[]>(() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
filterFn: 'includesString',
|
||||
meta: { filter: 'text' },
|
||||
header: ({ column }) => (
|
||||
<SortHeader label={t('common.name')} onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const tpl = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="shrink-0">
|
||||
{tpl.vm_type === 'qemu' ? (
|
||||
<OsLogo name={tpl.name} className="h-6 w-6" />
|
||||
) : tpl.group_icon ? (
|
||||
<span className="text-lg" dangerouslySetInnerHTML={{ __html: tpl.group_icon }} />
|
||||
) : (
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium truncate" title={tpl.name}>{tpl.name}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 260,
|
||||
},
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: ({ column }) => (
|
||||
<SortHeader label={t('templates.col_id')} onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} />
|
||||
),
|
||||
cell: ({ getValue }) => <span className="text-xs text-muted-foreground">{getValue<number>()}</span>,
|
||||
size: 70,
|
||||
},
|
||||
{
|
||||
accessorKey: 'vm_type',
|
||||
filterFn: multiSelectFilter,
|
||||
meta: { filter: 'select', formatOption: (v: string) => (v === 'lxc' ? t('templates.type_lxc') : t('templates.type_kvm')) },
|
||||
header: t('common.type'),
|
||||
cell: ({ getValue }) => {
|
||||
const type = getValue<string>();
|
||||
return (
|
||||
<Badge variant={type === 'lxc' ? 'secondary' : 'default'} className="text-xs font-normal">
|
||||
{type === 'lxc' ? t('templates.type_lxc') : t('templates.type_kvm')}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
size: 90,
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
accessorFn: (tpl) => tpl.group_name || '',
|
||||
filterFn: multiSelectFilter,
|
||||
meta: { filter: 'select' },
|
||||
header: t('templates.group'),
|
||||
cell: ({ getValue }) => {
|
||||
const v = getValue<string>();
|
||||
return v ? <span className="text-sm">{v}</span> : <span className="text-muted-foreground">—</span>;
|
||||
},
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
id: 'server',
|
||||
accessorFn: (tpl) => tpl.server_name || '',
|
||||
filterFn: multiSelectFilter,
|
||||
meta: { filter: 'select' },
|
||||
header: t('templates.server'),
|
||||
cell: ({ getValue }) => {
|
||||
const v = getValue<string>();
|
||||
return v ? <span className="text-sm text-muted-foreground">{v}</span> : <span className="text-muted-foreground">—</span>;
|
||||
},
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: ({ column }) => (
|
||||
<SortHeader label={t('templates.col_updated')} onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} />
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const v = getValue<string>();
|
||||
return <span className="text-xs text-muted-foreground whitespace-nowrap">{v ? new Date(v).toLocaleDateString() : '—'}</span>;
|
||||
},
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
accessorKey: 'default_disk',
|
||||
header: ({ column }) => (
|
||||
<SortHeader label={t('templates.col_size')} onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} />
|
||||
),
|
||||
cell: ({ getValue }) => <span className="text-xs tabular-nums">{getValue<number>()} GB</span>,
|
||||
size: 90,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const tpl = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{tpl.vm_type === 'qemu' && tpl.vmid != null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
title={t('templates.deploy')}
|
||||
onClick={() => setDeployTpl(tpl)}
|
||||
>
|
||||
<Rocket className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
title={t('templates.edit_group')}
|
||||
onClick={() => setEditTpl(tpl)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-destructive"
|
||||
title={t('common.delete')}
|
||||
onClick={async () => { if (await confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 110,
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
], [t]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: templates,
|
||||
columns,
|
||||
state: { sorting, columnFilters },
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
initialState: { pagination: { pageSize: 50 } },
|
||||
});
|
||||
|
||||
const nameFilterValue = (table.getColumn('name')?.getFilterValue() as string) ?? '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="relative flex-1 min-w-[220px] max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder={t('templates.search')}
|
||||
value={nameFilterValue}
|
||||
onChange={(e) => table.getColumn('name')?.setFilterValue(e.target.value || undefined)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" onClick={() => setDownloadOpen(true)}>
|
||||
<PackagePlus className="mr-2 h-4 w-4" />{t('templates.download_ct')}
|
||||
</Button>
|
||||
<Button onClick={() => setImportDialogOpen(true)}>
|
||||
<Download className="mr-2 h-4 w-4" />{t('templates.auto_import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3 p-4" aria-busy="true" aria-label={t('common.loading')}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
<Skeleton className="h-4 flex-1 max-w-[220px]" />
|
||||
<Skeleton className="hidden h-4 w-16 sm:block" />
|
||||
<Skeleton className="hidden h-4 w-24 md:block" />
|
||||
<Skeleton className="ml-auto h-4 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Monitor className="mb-3 h-10 w-10 opacity-40" />
|
||||
<p className="text-sm font-medium">{t('templates.no_templates')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const filterVariant = header.column.columnDef.meta?.filter;
|
||||
const sorted = header.column.getIsSorted();
|
||||
const ariaSort = !header.column.getCanSort()
|
||||
? undefined
|
||||
: sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : 'none';
|
||||
return (
|
||||
<TableHead key={header.id} style={{ width: header.getSize() }} aria-sort={ariaSort}>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{filterVariant && (
|
||||
<ColumnFilter
|
||||
column={header.column}
|
||||
variant={filterVariant}
|
||||
formatOption={header.column.columnDef.meta?.formatOption}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{templates.length > 0 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{t('instances.showing', 'Showing')} {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}–
|
||||
{Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
|
||||
table.getFilteredRowModel().rows.length,
|
||||
)}{' '}
|
||||
{t('instances.of', 'of')} {table.getFilteredRowModel().rows.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" className="h-8 w-8" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="px-2 tabular-nums">{table.getState().pagination.pageIndex + 1} / {Math.max(table.getPageCount(), 1)}</span>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeployDialog template={deployTpl} onClose={() => setDeployTpl(null)} />
|
||||
<EditTemplateDialog template={editTpl} onClose={() => setEditTpl(null)} />
|
||||
<DownloadCTDialog open={downloadOpen} onClose={() => setDownloadOpen(false)} />
|
||||
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('templates.auto_import')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Select value={importServerId} onValueChange={v => { if (v !== null) setImportServerId(v); }}>
|
||||
<SelectTrigger><SelectValue placeholder={t('templates.select_server')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleImport} disabled={!importServerId || autoImport.isPending} className="w-full">
|
||||
{autoImport.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('templates.import_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Monitor, Search, Download, Trash2, Loader2, Rocket, PackagePlus, Pencil } from 'lucide-react';
|
||||
import { OsLogo } from './OsLogo';
|
||||
import { DeployDialog, DownloadCTDialog, EditTemplateDialog } from './TemplateDialogs';
|
||||
import type { OSTemplate } from '@/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useTemplates, useTemplateGroups, useAutoImportTemplates, useDeleteTemplate } from '@/hooks/use-templates';
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { useHasPermission } from '@/lib/permissions';
|
||||
import OsTemplatesTab from './OsTemplatesTab';
|
||||
import ImageCatalogPanel from '@/features/images/ImageCatalogPanel';
|
||||
|
||||
type TabValue = 'os' | 'vm-images' | 'lxc' | 'iso' | 'repositories';
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [search, setSearch] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState<string>('');
|
||||
const [serverFilter, setServerFilter] = useState<string>('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [importServerId, setImportServerId] = useState<string>('');
|
||||
const [deployTpl, setDeployTpl] = useState<OSTemplate | null>(null);
|
||||
const [editTpl, setEditTpl] = useState<OSTemplate | null>(null);
|
||||
const [downloadOpen, setDownloadOpen] = useState(false);
|
||||
|
||||
const { data: groups = [] } = useTemplateGroups();
|
||||
const { data: templates = [], isLoading } = useTemplates(
|
||||
groupFilter ? Number(groupFilter) : undefined,
|
||||
serverFilter ? Number(serverFilter) : undefined,
|
||||
typeFilter || undefined,
|
||||
);
|
||||
const { data: servers = [] } = useServers();
|
||||
const autoImport = useAutoImportTemplates();
|
||||
const deleteTpl = useDeleteTemplate();
|
||||
|
||||
const filtered = templates.filter(tpl =>
|
||||
tpl.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleImport = () => {
|
||||
if (!importServerId) return;
|
||||
autoImport.mutate(Number(importServerId), {
|
||||
onSuccess: (res) => {
|
||||
const count = res.imported_count ?? 0;
|
||||
toast.success(
|
||||
count > 0
|
||||
? t('templates.imported_count', { count })
|
||||
: t('templates.imported_none'),
|
||||
);
|
||||
setImportDialogOpen(false);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
const [tab, setTab] = useState<TabValue>('os');
|
||||
const canManageImages = useHasPermission('template:manage');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold">{t('nav.templates')}</h1>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" onClick={() => setDownloadOpen(true)}>
|
||||
<PackagePlus className="mr-2 h-4 w-4" />{t('templates.download_ct')}
|
||||
</Button>
|
||||
<Button onClick={() => setImportDialogOpen(true)}>
|
||||
<Download className="mr-2 h-4 w-4" />{t('templates.auto_import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">{t('nav.templates')}</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input className="pl-9" placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<Select value={groupFilter} onValueChange={v => { if (v !== null) setGroupFilter(v); }}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]"><SelectValue placeholder={t('templates.all_groups')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('templates.all_groups')}</SelectItem>
|
||||
{groups.map(g => <SelectItem key={g.id} value={String(g.id)}>{g.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={serverFilter} onValueChange={v => { if (v !== null) setServerFilter(v); }}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]"><SelectValue placeholder={t('templates.all_servers')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('templates.all_servers')}</SelectItem>
|
||||
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={v => { if (v !== null) setTypeFilter(v); }}>
|
||||
<SelectTrigger className="w-full sm:w-[160px]"><SelectValue placeholder={t('templates.all_types')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('templates.all_types')}</SelectItem>
|
||||
<SelectItem value="qemu">{t('templates.type_kvm')}</SelectItem>
|
||||
<SelectItem value="lxc">{t('templates.type_lxc')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={(v) => { if (v) setTab(v as TabValue); }}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="os">{t('templates.tab_os')}</TabsTrigger>
|
||||
{canManageImages && <TabsTrigger value="vm-images">{t('templates.tab_vm_images')}</TabsTrigger>}
|
||||
{canManageImages && <TabsTrigger value="lxc">{t('templates.tab_lxc')}</TabsTrigger>}
|
||||
{canManageImages && <TabsTrigger value="iso">{t('templates.tab_iso')}</TabsTrigger>}
|
||||
{canManageImages && <TabsTrigger value="repositories">{t('templates.tab_repositories')}</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
{/* Templates grid */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-16 text-muted-foreground">{t('common.loading')}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Monitor className="mx-auto h-12 w-12 mb-3 opacity-50" />
|
||||
<p>{t('templates.no_templates')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map(tpl => (
|
||||
<Card key={tpl.id} className="group">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="shrink-0">
|
||||
{tpl.vm_type === 'qemu' ? (
|
||||
<OsLogo name={tpl.name} className="h-7 w-7" />
|
||||
) : tpl.group_icon ? (
|
||||
<span className="text-xl" dangerouslySetInnerHTML={{ __html: tpl.group_icon }} />
|
||||
) : (
|
||||
<Monitor className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate" title={tpl.name}>{tpl.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{tpl.group_name}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0"
|
||||
title={t('templates.edit_group')}
|
||||
onClick={() => setEditTpl(tpl)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0 text-destructive"
|
||||
onClick={async () => { if (await confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={tpl.vm_type === 'lxc' ? 'secondary' : 'default'} className="text-xs">
|
||||
{tpl.vm_type === 'lxc' ? t('templates.type_lxc') : t('templates.type_kvm')}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">{tpl.default_cores} vCPU</Badge>
|
||||
<Badge variant="outline" className="text-xs">{tpl.default_memory} MB</Badge>
|
||||
<Badge variant="outline" className="text-xs">{tpl.default_disk} GB</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{tpl.server_name}</span>
|
||||
<span>{tpl.vmid != null ? `VMID: ${tpl.vmid}` : t('templates.file_template')}</span>
|
||||
</div>
|
||||
{tpl.vm_type === 'qemu' && tpl.vmid != null && (
|
||||
<Button size="sm" className="w-full" onClick={() => setDeployTpl(tpl)}>
|
||||
<Rocket className="mr-1.5 h-4 w-4" />{t('templates.deploy')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeployDialog template={deployTpl} onClose={() => setDeployTpl(null)} />
|
||||
<EditTemplateDialog template={editTpl} onClose={() => setEditTpl(null)} />
|
||||
<DownloadCTDialog open={downloadOpen} onClose={() => setDownloadOpen(false)} />
|
||||
|
||||
{/* Import Dialog */}
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('templates.auto_import')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Select value={importServerId} onValueChange={v => { if (v !== null) setImportServerId(v); }}>
|
||||
<SelectTrigger><SelectValue placeholder={t('templates.select_server')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleImport} disabled={!importServerId || autoImport.isPending} className="w-full">
|
||||
{autoImport.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('templates.import_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TabsContent value="os">
|
||||
<OsTemplatesTab />
|
||||
</TabsContent>
|
||||
{canManageImages && (
|
||||
<TabsContent value="vm-images">
|
||||
<ImageCatalogPanel section="vm-images" />
|
||||
</TabsContent>
|
||||
)}
|
||||
{canManageImages && (
|
||||
<TabsContent value="lxc">
|
||||
<ImageCatalogPanel section="lxc" />
|
||||
</TabsContent>
|
||||
)}
|
||||
{canManageImages && (
|
||||
<TabsContent value="iso">
|
||||
<ImageCatalogPanel section="iso" />
|
||||
</TabsContent>
|
||||
)}
|
||||
{canManageImages && (
|
||||
<TabsContent value="repositories">
|
||||
<ImageCatalogPanel section="repositories" />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { Link } from 'react-router';
|
||||
import { Box, Monitor } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -14,9 +15,10 @@ function GuestNodeImpl({ data }: NodeProps) {
|
||||
const extraTags = guest.tags.length - VISIBLE_TAGS;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Link
|
||||
to={`/instances/${guest.server_id}/${guest.vmid}?node=${guest.node}&type=${guest.type}`}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
@@ -47,7 +49,7 @@ function GuestNodeImpl({ data }: NodeProps) {
|
||||
</div>
|
||||
) : null}
|
||||
<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) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { IPAMNetwork, IPAMPool, IPAMAllocation } from '@/types';
|
||||
import type {
|
||||
IPAMNetwork,
|
||||
IPAMPool,
|
||||
IPAMAllocation,
|
||||
GuestAddress,
|
||||
GuestAddressApplyStatus,
|
||||
} from '@/types';
|
||||
|
||||
export const ipamKeys = {
|
||||
networks: ['ipam-networks'] as const,
|
||||
@@ -251,11 +257,17 @@ export function useCleanupOrphans() {
|
||||
});
|
||||
}
|
||||
|
||||
export interface IPAMLinkSkipped {
|
||||
ip_address: string;
|
||||
resource_name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function useLinkAllocations() {
|
||||
const invalidate = useInvalidateIPAM();
|
||||
return useMutation({
|
||||
mutationFn: (body?: { server_id?: number; network_id?: number }) =>
|
||||
apiClient.post<{ linked: unknown[]; not_found: unknown[] }>('/ipam/api/link-allocations', body ?? {}),
|
||||
apiClient.post<{ linked: unknown[]; not_found: IPAMLinkSkipped[] }>('/ipam/api/link-allocations', body ?? {}),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
@@ -283,3 +295,95 @@ export function useIPHistoryLookup() {
|
||||
mutationFn: (ip: string) => apiClient.get<unknown[]>(`/ipam/api/history/ip/${encodeURIComponent(ip)}`),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ==================== Адреса инстанса (несколько IP на гостя) ====================
|
||||
|
||||
export interface GuestAddressApplyResult {
|
||||
address: GuestAddress;
|
||||
applied: boolean;
|
||||
apply_status: GuestAddressApplyStatus;
|
||||
persist?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const guestAddressKey = (serverId: number, vmid: number) =>
|
||||
['guest-addresses', serverId, vmid] as const;
|
||||
|
||||
export function useGuestAddresses(serverId: number, vmid: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: guestAddressKey(serverId, vmid),
|
||||
queryFn: () =>
|
||||
apiClient.get<{ addresses: GuestAddress[]; count: number }>(
|
||||
`/ipam/api/guests/${serverId}/${vmid}/addresses`,
|
||||
),
|
||||
enabled: enabled && serverId > 0 && vmid > 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** Инвалидация адресов гостя + всего, что показывает IP инстанса. */
|
||||
function useInvalidateGuestAddresses(serverId: number, vmid: number) {
|
||||
const qc = useQueryClient();
|
||||
const invalidateIPAM = useInvalidateIPAM();
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: guestAddressKey(serverId, vmid) });
|
||||
qc.invalidateQueries({ queryKey: ['instances'] });
|
||||
qc.invalidateQueries({ queryKey: ['vms'] });
|
||||
invalidateIPAM();
|
||||
};
|
||||
}
|
||||
|
||||
export function useAddGuestAddress(serverId: number, vmid: number) {
|
||||
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
|
||||
return useMutation({
|
||||
mutationFn: (body: {
|
||||
network_id: number;
|
||||
pool_id?: number;
|
||||
ip_address?: string;
|
||||
target_interface?: string;
|
||||
make_primary?: boolean;
|
||||
hostname?: string;
|
||||
notes?: string;
|
||||
}) =>
|
||||
apiClient.post<GuestAddressApplyResult>(
|
||||
`/ipam/api/guests/${serverId}/${vmid}/addresses`,
|
||||
body,
|
||||
),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplyGuestAddress(serverId: number, vmid: number) {
|
||||
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
|
||||
return useMutation({
|
||||
mutationFn: (allocationId: number) =>
|
||||
apiClient.post<GuestAddressApplyResult>(
|
||||
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}/apply`,
|
||||
{},
|
||||
),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetPrimaryGuestAddress(serverId: number, vmid: number) {
|
||||
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
|
||||
return useMutation({
|
||||
mutationFn: (allocationId: number) =>
|
||||
apiClient.post<{ address: GuestAddress }>(
|
||||
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}/primary`,
|
||||
{},
|
||||
),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveGuestAddress(serverId: number, vmid: number) {
|
||||
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
|
||||
return useMutation({
|
||||
mutationFn: (allocationId: number) =>
|
||||
apiClient.delete<{ released: string; removed_from_guest: boolean; error?: string | null }>(
|
||||
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}`,
|
||||
),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Monitor,
|
||||
Network,
|
||||
HardDrive,
|
||||
HardDriveDownload,
|
||||
Archive,
|
||||
Globe,
|
||||
Settings,
|
||||
@@ -49,7 +48,6 @@ export const navGroups: NavGroup[] = [
|
||||
{ label: 'Nodes', icon: Server, path: '/nodes', permission: 'server:view' },
|
||||
{ label: 'Cluster', icon: Network, path: '/cluster', permission: 'cluster:manage' },
|
||||
{ label: 'Templates', icon: HardDrive, path: '/templates', permission: 'template:view' },
|
||||
{ label: 'Images', icon: HardDriveDownload, path: '/images', permission: 'template:manage' },
|
||||
{ label: 'Backups', icon: Archive, path: '/backups', permission: 'backup:view' },
|
||||
{ label: 'Tasks', icon: ClipboardList, path: '/tasks', permission: 'vm:view' },
|
||||
],
|
||||
|
||||
@@ -1230,7 +1230,15 @@
|
||||
"group": "Group",
|
||||
"edit_group": "Edit group",
|
||||
"select_group": "Select group",
|
||||
"group_updated": "Group updated"
|
||||
"group_updated": "Group updated",
|
||||
"tab_os": "Operating Systems",
|
||||
"tab_vm_images": "VM Images",
|
||||
"tab_lxc": "LXC Templates",
|
||||
"tab_iso": "ISO Images",
|
||||
"tab_repositories": "Repositories",
|
||||
"col_id": "id",
|
||||
"col_updated": "Updated",
|
||||
"col_size": "Size"
|
||||
},
|
||||
"storages": {
|
||||
"title": "Storages",
|
||||
@@ -1246,6 +1254,9 @@
|
||||
"path": "Path",
|
||||
"server": "Server",
|
||||
"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",
|
||||
"datastore": "Datastore",
|
||||
"username": "Username",
|
||||
@@ -1416,6 +1427,7 @@
|
||||
"no_unlinked": "All VMs are linked",
|
||||
"link_allocations": "Scan & link",
|
||||
"linked": "Linked",
|
||||
"link_skipped": "Skipped: {{count}}",
|
||||
"ip_history": "IP history lookup",
|
||||
"pool_required_hint": "Auto-allocation requires an auto-assign pool in the network.",
|
||||
"resource_required": "Please specify a resource before allocating an IP.",
|
||||
@@ -1480,7 +1492,40 @@
|
||||
"allocation_created": "Allocation created",
|
||||
"allocation_updated": "Allocation updated",
|
||||
"delete_allocation_confirm": "Release IP {{ip}}?",
|
||||
"notes": "Notes"
|
||||
"notes": "Notes",
|
||||
"addresses": "IP addresses",
|
||||
"add_address": "Add IP",
|
||||
"add_address_title": "Add IP address",
|
||||
"add_address_description": "The address is allocated in IPAM and attached to the guest interface as an alias.",
|
||||
"add_address_submit": "Assign address",
|
||||
"no_addresses": "No addresses in IPAM",
|
||||
"primary": "primary",
|
||||
"make_primary": "Make primary address",
|
||||
"primary_managed_by_nic": "The primary address comes from the NIC config",
|
||||
"from_nic_config": "from NIC config",
|
||||
"applied": "applied",
|
||||
"not_applied": "not applied",
|
||||
"runtime_only": "until reboot",
|
||||
"runtime_only_hint": "Address is up, but the guest network stack was not recognised — it will not survive a reboot",
|
||||
"discovered": "discovered",
|
||||
"apply": "Apply",
|
||||
"release": "Release",
|
||||
"release_title": "Release address?",
|
||||
"release_description": "Address {{ip}} will be removed from the guest interface and released in IPAM.",
|
||||
"address_added": "Address {{ip}} assigned",
|
||||
"address_released": "Address {{ip}} released",
|
||||
"address_released_not_removed": "{{ip}} released in IPAM, but could not be removed from the guest: {{error}}",
|
||||
"address_runtime_only": "{{ip}} is up but not persisted: guest network stack was not recognised",
|
||||
"address_reserved_not_applied": "{{ip}} reserved but not applied: {{error}}",
|
||||
"address_mode": "Address",
|
||||
"next_free": "Next free",
|
||||
"manual_ip": "Enter manually",
|
||||
"target_interface": "Guest interface",
|
||||
"any_pool": "Any",
|
||||
"pool": "Pool",
|
||||
"err_network_required": "Select a network",
|
||||
"err_bad_ip": "Invalid IP address",
|
||||
"extra_addresses": "+{{count}} more"
|
||||
},
|
||||
"logs": {
|
||||
"level": "Level",
|
||||
|
||||
@@ -1212,7 +1212,15 @@
|
||||
"group": "Группа",
|
||||
"edit_group": "Изменить группу",
|
||||
"select_group": "Выберите группу",
|
||||
"group_updated": "Группа обновлена"
|
||||
"group_updated": "Группа обновлена",
|
||||
"tab_os": "Операционные системы",
|
||||
"tab_vm_images": "Образы VM",
|
||||
"tab_lxc": "Шаблоны LXC",
|
||||
"tab_iso": "ISO-образы",
|
||||
"tab_repositories": "Репозитории",
|
||||
"col_id": "id",
|
||||
"col_updated": "Обновлён",
|
||||
"col_size": "Размер"
|
||||
},
|
||||
"storages": {
|
||||
"title": "Хранилища",
|
||||
@@ -1228,6 +1236,9 @@
|
||||
"path": "Путь",
|
||||
"server": "Сервер",
|
||||
"export": "Экспорт",
|
||||
"scan_exports": "Найти экспорты на сервере",
|
||||
"server_required": "Сначала укажите адрес сервера",
|
||||
"no_exports_found": "Экспорты не найдены — проверьте /etc/exports на сервере",
|
||||
"share": "Шара",
|
||||
"datastore": "Datastore",
|
||||
"username": "Пользователь",
|
||||
@@ -1398,6 +1409,7 @@
|
||||
"no_unlinked": "Все VM связаны",
|
||||
"link_allocations": "Сканировать и связать",
|
||||
"linked": "Связано",
|
||||
"link_skipped": "Пропущено: {{count}}",
|
||||
"ip_history": "История по IP",
|
||||
"pool_required_hint": "Авто-выделение требует пул с auto-assign в сети.",
|
||||
"resource_required": "Укажите ресурс перед выделением IP.",
|
||||
@@ -1462,7 +1474,40 @@
|
||||
"allocation_created": "Аллокация создана",
|
||||
"allocation_updated": "Аллокация обновлена",
|
||||
"delete_allocation_confirm": "Освободить IP {{ip}}?",
|
||||
"notes": "Заметки"
|
||||
"notes": "Заметки",
|
||||
"addresses": "IP-адреса",
|
||||
"add_address": "Добавить IP",
|
||||
"add_address_title": "Добавить IP-адрес",
|
||||
"add_address_description": "Адрес выделяется в IPAM и навешивается на интерфейс гостя дополнительным (alias).",
|
||||
"add_address_submit": "Выдать адрес",
|
||||
"no_addresses": "Адресов в IPAM нет",
|
||||
"primary": "основной",
|
||||
"make_primary": "Сделать основным адресом",
|
||||
"primary_managed_by_nic": "Основной адрес задаётся конфигом интерфейса",
|
||||
"from_nic_config": "из конфига NIC",
|
||||
"applied": "применён",
|
||||
"not_applied": "не применён",
|
||||
"runtime_only": "до перезагрузки",
|
||||
"runtime_only_hint": "Адрес поднят, но стек сети гостя не распознан — после перезагрузки исчезнет",
|
||||
"discovered": "обнаружен",
|
||||
"apply": "Применить",
|
||||
"release": "Освободить",
|
||||
"release_title": "Освободить адрес?",
|
||||
"release_description": "Адрес {{ip}} будет снят с интерфейса гостя и освобождён в IPAM.",
|
||||
"address_added": "Адрес {{ip}} назначен",
|
||||
"address_released": "Адрес {{ip}} освобождён",
|
||||
"address_released_not_removed": "{{ip}} освобождён в IPAM, но снять его с гостя не удалось: {{error}}",
|
||||
"address_runtime_only": "{{ip}} поднят, но не закреплён: стек сети гостя не распознан",
|
||||
"address_reserved_not_applied": "{{ip}} зарезервирован, но не применён: {{error}}",
|
||||
"address_mode": "Адрес",
|
||||
"next_free": "Следующий свободный",
|
||||
"manual_ip": "Указать вручную",
|
||||
"target_interface": "Интерфейс гостя",
|
||||
"any_pool": "Любой",
|
||||
"pool": "Пул",
|
||||
"err_network_required": "Выберите сеть",
|
||||
"err_bad_ip": "Некорректный IP-адрес",
|
||||
"extra_addresses": "ещё {{count}}"
|
||||
},
|
||||
"logs": {
|
||||
"level": "Уровень",
|
||||
|
||||
@@ -22,7 +22,6 @@ const NodesPage = lazy(() => import('./features/nodes/NodesPage.tsx'));
|
||||
const NodeDetailPage = lazy(() => import('./features/nodes/NodeDetailPage.tsx'));
|
||||
const ClusterPage = lazy(() => import('./features/cluster/ClusterPage.tsx'));
|
||||
const TemplatesPage = lazy(() => import('./features/templates/TemplatesPage.tsx'));
|
||||
const ImagesPage = lazy(() => import('./features/images/ImagesPage.tsx'));
|
||||
const BackupsPage = lazy(() => import('./features/backups/BackupsPage.tsx'));
|
||||
const TasksPage = lazy(() => import('./features/tasks/TasksPage.tsx'));
|
||||
const TopologyPage = lazy(() => import('./features/topology/TopologyPage.tsx'));
|
||||
@@ -109,7 +108,7 @@ export const router = createBrowserRouter([
|
||||
{ path: '/appstore/:appId', element: <Page perm="app:view"><AppDetailPage /></Page> },
|
||||
{ path: '/my-apps', element: <Page perm="app:view"><MyAppsPage /></Page> },
|
||||
{ path: '/scripts', element: <Page perm="script:view"><ScriptsPage /></Page> },
|
||||
{ path: '/images', element: <Page perm="template:manage"><ImagesPage /></Page> },
|
||||
{ path: '/images', element: <Navigate to="/templates" replace /> },
|
||||
{ path: '/backups', element: <Page perm="backup:view"><BackupsPage /></Page> },
|
||||
{ path: '/tasks', element: <Page perm="vm:view"><TasksPage /></Page> },
|
||||
{ path: '/topology', element: <Page perm="network:view"><TopologyPage /></Page> },
|
||||
|
||||
@@ -116,6 +116,14 @@ export interface VMInstance {
|
||||
netin_rate?: number;
|
||||
netout_rate?: number;
|
||||
ip_address?: string;
|
||||
/** Все адреса гостя; ip_address — основной из этого списка */
|
||||
ips?: Array<{
|
||||
ip: string;
|
||||
is_primary: boolean;
|
||||
assignment_kind?: 'primary' | 'alias';
|
||||
target_interface?: string | null;
|
||||
apply_status?: GuestAddressApplyStatus | null;
|
||||
}>;
|
||||
tags?: string;
|
||||
template?: boolean;
|
||||
description?: string;
|
||||
@@ -397,10 +405,41 @@ export interface IPAMAllocation {
|
||||
fqdn?: string;
|
||||
allocation_type?: string;
|
||||
notes?: string;
|
||||
/** Основной адрес гостя — он попадает в колонку IP списка инстансов */
|
||||
is_primary?: boolean;
|
||||
/** primary — задан конфигом NIC, alias — навешен внутри гостя */
|
||||
assignment_kind?: 'primary' | 'alias';
|
||||
target_interface?: string | null;
|
||||
apply_status?: GuestAddressApplyStatus | null;
|
||||
apply_error?: string | null;
|
||||
applied_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Состояние применения alias-адреса к живому гостю */
|
||||
export type GuestAddressApplyStatus = 'applied' | 'runtime_only' | 'pending' | 'failed';
|
||||
|
||||
/** Один адрес инстанса на вкладке «Сеть» */
|
||||
export interface GuestAddress {
|
||||
id: number;
|
||||
ip_address: string;
|
||||
prefix?: string | null;
|
||||
network_id: number;
|
||||
network_name?: string | null;
|
||||
gateway?: string | null;
|
||||
mac_address?: string | null;
|
||||
status: string;
|
||||
is_primary: boolean;
|
||||
assignment_kind: 'primary' | 'alias';
|
||||
target_interface?: string | null;
|
||||
apply_status?: GuestAddressApplyStatus | null;
|
||||
apply_error?: string | null;
|
||||
applied_at?: string | null;
|
||||
hostname?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
// ==================== Template Types ====================
|
||||
|
||||
export interface OSTemplateGroup {
|
||||
|
||||
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,
|
||||
"storage:view": true,
|
||||
"backup:view": true, "backup:create": true,
|
||||
"ipam:view": true,
|
||||
"user:view": true,
|
||||
"log:view": true, "log:export": 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,
|
||||
"template:view": true,
|
||||
"storage:view": true,
|
||||
"ipam:view": true,
|
||||
"notification:view": true, "notification:manage": true
|
||||
}', TRUE, TRUE),
|
||||
('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,
|
||||
"lxc:view": true,
|
||||
"template:view": true,
|
||||
"storage:view": true,
|
||||
"ipam:view": true
|
||||
"storage:view": true
|
||||
}', TRUE, TRUE)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
# Что делает:
|
||||
# 1) скачивает cloud-образы из curated-каталога (см. backend/app/catalog/builtin.py)
|
||||
# 2) прогоняет каждый через virt-customize: ставит qemu-guest-agent, включает
|
||||
# сервис и (опц.) обнуляет machine-id
|
||||
# сервис, снимает блокировку guest-exec (RHEL-семейство) и (опц.) обнуляет
|
||||
# machine-id
|
||||
# 3) считает sha256 и пишет sha256sums.txt
|
||||
# 4) генерирует mirrors.json — готовый список для добавления через
|
||||
# POST /api/images/mirrors (или ручного ввода в UI)
|
||||
@@ -21,6 +22,7 @@
|
||||
# Пример:
|
||||
# BASE_URL="https://mirror.lan/pve-images" ./patch-cloud-images.sh
|
||||
# INCLUDE_ARM64=1 OUTDIR=/srv/images ./patch-cloud-images.sh
|
||||
# ONLY="rocky alma centos fedora" ./patch-cloud-images.sh # только RHEL-семейство
|
||||
#
|
||||
# Установка: скопируйте файл на машину-патчер и запустите. Скрипт самодостаточен.
|
||||
|
||||
@@ -31,6 +33,10 @@ OUTDIR="${OUTDIR:-./patched-images}" # куда складывать гот
|
||||
INSTALL_PKG="${INSTALL_PKG:-qemu-guest-agent}"
|
||||
INCLUDE_ARM64="${INCLUDE_ARM64:-0}" # 1 — патчить и arm64 (нужен qemu-user-static)
|
||||
RESET_MACHINE_ID="${RESET_MACHINE_ID:-1}" # 1 — обнулить /etc/machine-id (уникальный id у клонов)
|
||||
UNBLOCK_RPC="${UNBLOCK_RPC:-1}" # 1 — снять блокировку guest-exec/guest-file-* в qemu-ga
|
||||
ONLY="${ONLY:-}" # фильтр образов по id/os, через пробел или запятую
|
||||
# напр. ONLY="rocky alma centos fedora"
|
||||
# пусто — собирать весь каталог
|
||||
FORCE_DOWNLOAD="${FORCE_DOWNLOAD:-0}" # 1 — перекачать образы даже если уже скачаны
|
||||
AUTO_INSTALL="${AUTO_INSTALL:-1}" # 1 — доустановить недостающие зависимости самому
|
||||
BASE_URL="${BASE_URL:-}" # публичный префикс зеркала для url в mirrors.json
|
||||
@@ -105,6 +111,19 @@ pm_install() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Подходит ли образ под фильтр ONLY (совпадение подстроки с id или os)
|
||||
match_only() {
|
||||
local id="$1" os="$2" pat
|
||||
if [ -z "$ONLY" ]; then
|
||||
return 0
|
||||
fi
|
||||
for pat in $(echo "$ONLY" | tr ',' ' '); do
|
||||
case "$id" in *"$pat"*) return 0 ;; esac
|
||||
case "$os" in *"$pat"*) return 0 ;; esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Preflight ──────────────────────────────────────────────────────────────────
|
||||
step "Проверка окружения"
|
||||
|
||||
@@ -155,11 +174,26 @@ fi
|
||||
|
||||
mkdir -p "$RAW_DIR"
|
||||
|
||||
# ── Снятие блокировки RPC у guest agent ───────────────────────────────────────
|
||||
# Rocky / AlmaLinux / CentOS Stream / Fedora ставят qemu-guest-agent с
|
||||
# урезанным набором RPC — в /etc/sysconfig/qemu-ga, откуда systemd-юнит берёт
|
||||
# аргументы командной строки агента. Разметка файла менялась трижды:
|
||||
# FILTER_RPC_ARGS="--allow-rpcs=..." — актуальные пакеты (RHEL 9/10, Fedora):
|
||||
# allow-list, где guest-exec просто нет
|
||||
# BLOCK_RPCS=... — qemu-ga 8.1+, block-list
|
||||
# BLACKLIST_RPC=... — qemu-ga < 8.1, block-list
|
||||
# Пустое значение любой из них = агент без ограничений, как в Debian/Ubuntu,
|
||||
# где фильтра нет вовсе. Без этого панель не может выполнить скрипт в ВМ,
|
||||
# навесить дополнительный IP и расширить ФС после ресайза диска: агент
|
||||
# отвечает "The command guest-exec has been disabled for this instance".
|
||||
# Закомментированные строки не трогаем, в Debian/Ubuntu файла нет вообще.
|
||||
QGA_UNBLOCK_CMD='f=/etc/sysconfig/qemu-ga; if [ -f "$f" ]; then sed -i -e "s/^[[:space:]]*FILTER_RPC_ARGS=.*/FILTER_RPC_ARGS=/" -e "s/^[[:space:]]*BLACKLIST_RPC=.*/BLACKLIST_RPC=/" -e "s/^[[:space:]]*BLOCK_RPCS=.*/BLOCK_RPCS=/" "$f"; echo "qemu-ga RPC unblocked:" $(grep -E "^(FILTER_RPC_ARGS|BLACKLIST_RPC|BLOCK_RPCS)=" "$f"); else echo "qemu-ga: no /etc/sysconfig/qemu-ga, nothing to unblock"; fi'
|
||||
|
||||
# ── Обход каталога ─────────────────────────────────────────────────────────────
|
||||
SUMS_FILE="$OUTDIR/sha256sums.txt"
|
||||
: > "$SUMS_FILE"
|
||||
declare -a MIRROR_ROWS=() # строки для mirrors.json: os|version|arch|name|file|sha
|
||||
FAILED=0; DONE=0; SKIPPED=0
|
||||
FAILED=0; DONE=0; SKIPPED=0; FILTERED=0
|
||||
|
||||
for row in "${IMAGES[@]}"; do
|
||||
IFS='|' read -r id os version name arch url <<< "$row"
|
||||
@@ -168,6 +202,10 @@ for row in "${IMAGES[@]}"; do
|
||||
SKIPPED=$((SKIPPED+1)); continue
|
||||
fi
|
||||
|
||||
if ! match_only "$id" "$os"; then
|
||||
FILTERED=$((FILTERED+1)); continue
|
||||
fi
|
||||
|
||||
file="$(basename "$url")"
|
||||
raw="$RAW_DIR/$file"
|
||||
out="$OUTDIR/$file"
|
||||
@@ -193,7 +231,14 @@ for row in "${IMAGES[@]}"; do
|
||||
info "virt-customize: ставлю $INSTALL_PKG"
|
||||
VC_ARGS=(-a "$out" --install "$INSTALL_PKG"
|
||||
--run-command "systemctl enable ${INSTALL_PKG}.service")
|
||||
[ "$RESET_MACHINE_ID" = "1" ] && VC_ARGS+=(--run-command "truncate -s 0 /etc/machine-id")
|
||||
# if, а не `[ ... ] && ...`: при set -e ложное условие в конце AND-списка
|
||||
# завершает скрипт.
|
||||
if [ "$UNBLOCK_RPC" = "1" ]; then
|
||||
VC_ARGS+=(--run-command "$QGA_UNBLOCK_CMD")
|
||||
fi
|
||||
if [ "$RESET_MACHINE_ID" = "1" ]; then
|
||||
VC_ARGS+=(--run-command "truncate -s 0 /etc/machine-id")
|
||||
fi
|
||||
if ! LIBGUESTFS_BACKEND="${LIBGUESTFS_BACKEND:-direct}" virt-customize "${VC_ARGS[@]}"; then
|
||||
err "virt-customize не смог обработать $out — пропускаю."
|
||||
rm -f "$out"; FAILED=$((FAILED+1)); continue
|
||||
@@ -210,8 +255,12 @@ done
|
||||
# ── mirrors.json ───────────────────────────────────────────────────────────────
|
||||
step "Генерация mirrors.json"
|
||||
MIRRORS_FILE="$OUTDIR/mirrors.json"
|
||||
# Тот же set -e: с заданным BASE_URL ложное условие оборвало бы скрипт
|
||||
# ровно перед генерацией mirrors.json.
|
||||
url_prefix="${BASE_URL%/}"
|
||||
[ -z "$url_prefix" ] && url_prefix="REPLACE_ME"
|
||||
if [ -z "$url_prefix" ]; then
|
||||
url_prefix="REPLACE_ME"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "["
|
||||
@@ -237,8 +286,15 @@ url_prefix="${BASE_URL%/}"
|
||||
# ── Итог ───────────────────────────────────────────────────────────────────────
|
||||
step "Итог"
|
||||
ok "Готово образов: $DONE"
|
||||
[ "$SKIPPED" -gt 0 ] && info "Пропущено arm64: $SKIPPED (INCLUDE_ARM64=1 чтобы включить)"
|
||||
[ "$FAILED" -gt 0 ] && warn "С ошибками: $FAILED"
|
||||
if [ "$SKIPPED" -gt 0 ]; then
|
||||
info "Пропущено arm64: $SKIPPED (INCLUDE_ARM64=1 чтобы включить)"
|
||||
fi
|
||||
if [ "$FILTERED" -gt 0 ]; then
|
||||
info "Отфильтровано по ONLY=\"$ONLY\": $FILTERED"
|
||||
fi
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
warn "С ошибками: $FAILED"
|
||||
fi
|
||||
echo
|
||||
info "Готовые образы: $OUTDIR/"
|
||||
info "Контрольные суммы: $SUMS_FILE"
|
||||
|
||||
Reference in New Issue
Block a user