Сравнить коммиты
15
Коммитов
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
73d3f5259f | ||
|
|
ebc9b99991 | ||
|
|
5cf0a6b640 | ||
|
|
02a6d544cf | ||
|
|
52e2033031 | ||
|
|
0b0496f6bb | ||
|
|
374bcbb29f | ||
|
|
d322c1352c | ||
|
|
bb87f52834 | ||
|
|
1da491c9e1 | ||
|
|
71f7ee2ef3 | ||
|
|
a175a6f661 | ||
|
|
9e54614869 | ||
|
|
0c95e8e516 | ||
|
|
77a263ec86 |
@@ -0,0 +1,31 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Проверка синтаксиса Python
|
||||||
|
run: python -m compileall -q web scripts
|
||||||
|
|
||||||
|
- name: Проверка синтаксиса bash-скриптов
|
||||||
|
run: |
|
||||||
|
bash -n install.sh bootstrap.sh install_virtuality_node.sh setup_github_sync.sh
|
||||||
|
for script in scripts/*.sh; do bash -n "$script"; done
|
||||||
|
|
||||||
|
- name: Установка зависимостей
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r web/requirements.txt pytest httpx
|
||||||
|
|
||||||
|
- name: Тесты
|
||||||
|
run: pytest -q
|
||||||
+33
-17
@@ -112,7 +112,16 @@ passwd root
|
|||||||
- создание VM из web-интерфейса;
|
- создание VM из web-интерфейса;
|
||||||
- выбор сетевого режима при создании VM: `virtuality-nat` или `br0`;
|
- выбор сетевого режима при создании VM: `virtuality-nat` или `br0`;
|
||||||
- прогресс создания VM и live-лог `virt-install`;
|
- прогресс создания VM и live-лог `virt-install`;
|
||||||
- страница деталей VM: `dominfo`, VNC display, IP, диски, сетевые интерфейсы.
|
- страница деталей VM: `dominfo`, VNC display, IP, диски, сетевые интерфейсы;
|
||||||
|
- web-консоль noVNC прямо в браузере;
|
||||||
|
- cloud-образы Ubuntu/Debian (x86_64 и ARM64) и создание VM из образа с cloud-init за минуту;
|
||||||
|
- клонирование VM и шаблоны (эталонные VM с блокировкой запуска);
|
||||||
|
- снапшоты VM: создание, откат, удаление;
|
||||||
|
- бэкапы дисков VM со сжатием, ротацией и ночным расписанием;
|
||||||
|
- HTTPS из коробки (самоподписанный сертификат), CSRF-защита форм, PAM-аутентификация, защита от перебора пароля;
|
||||||
|
- графики CPU/RAM хоста на дашборде;
|
||||||
|
- быстрый libvirt-backend с fallback на virsh для слабого железа;
|
||||||
|
- тесты pytest и CI на GitHub Actions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -156,11 +165,13 @@ Orange Pi 5 ARM64: желательно 8/16/32 GB RAM и NVMe
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Cockpit: https://SERVER_IP:9090
|
Cockpit: https://SERVER_IP:9090
|
||||||
Virtuality UI: http://SERVER_IP:8088
|
Virtuality UI: https://SERVER_IP:8088
|
||||||
VNC: 5900-5999/tcp
|
VNC: 5900-5999/tcp
|
||||||
SSH: 22/tcp
|
SSH: 22/tcp
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Панель работает по HTTPS с самоподписанным сертификатом — браузер один раз попросит подтвердить исключение. Отключить TLS: `VIRTUALITY_TLS=0` при установке.
|
||||||
|
|
||||||
Изменить порт web-панели:
|
Изменить порт web-панели:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -224,12 +235,18 @@ http://SERVER_IP:8088
|
|||||||
Основные разделы:
|
Основные разделы:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/ # дашборд
|
/ # дашборд с графиками CPU/RAM
|
||||||
/host # профиль хоста и проверки
|
/host # профиль хоста и проверки
|
||||||
/iso # ISO-менеджер
|
/iso # ISO-менеджер
|
||||||
/network # NAT Router и проброс портов
|
/disk-images # готовые образы дисков
|
||||||
/operations # журнал операций
|
/cloud-images # cloud-образы Ubuntu/Debian
|
||||||
/vm/create # создание VM
|
/vm/create # создание VM из ISO или готового диска
|
||||||
|
/vm/create-cloud # создание VM из cloud-образа с cloud-init
|
||||||
|
/backups # бэкапы VM и ночное расписание
|
||||||
|
/network # NAT Router и проброс портов
|
||||||
|
/operations # журнал операций
|
||||||
|
/logs # центр журналов
|
||||||
|
/update # центр обновлений
|
||||||
```
|
```
|
||||||
|
|
||||||
Переустановка web-панели:
|
Переустановка web-панели:
|
||||||
@@ -414,19 +431,18 @@ journalctl -u virtuality-web -f
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- ARM64 cloud-image templates;
|
|
||||||
- cloud-init для быстрых VM;
|
|
||||||
- web-console/noVNC;
|
|
||||||
- управление storage pools;
|
- управление storage pools;
|
||||||
- backup/snapshot manager;
|
|
||||||
- сетевой менеджер bridge/VLAN;
|
- сетевой менеджер bridge/VLAN;
|
||||||
- роли и права пользователей;
|
- журнал событий.
|
||||||
- журнал событий;
|
|
||||||
- автообновление;
|
Сделано из прошлого roadmap: cloud-image шаблоны (x86_64 и ARM64), cloud-init,
|
||||||
- кластеризация.
|
web-console/noVNC, снапшоты и бэкапы, автообновление.
|
||||||
|
|
||||||
|
Сознательно не планируется: кластеризация, HA, Ceph/ZFS и роли пользователей —
|
||||||
|
Virtuality остаётся лёгкой панелью для одиночных нод.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
Рекомендуемая лицензия: **MIT License**.
|
**MIT License** — см. [`LICENSE`](LICENSE).
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.9.6
|
0.9.13
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
APP_DIR="${VIRTUALITY_APP_DIR:-/opt/virtuality/web}"
|
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
DISK_IMAGES_DIR="/var/lib/virtuality/disk-images"
|
DISK_IMAGES_DIR="/var/lib/virtuality/disk-images"
|
||||||
|
|
||||||
mkdir -p "$DISK_IMAGES_DIR"
|
mkdir -p "$DISK_IMAGES_DIR"
|
||||||
chmod 755 "$DISK_IMAGES_DIR"
|
chmod 755 "$DISK_IMAGES_DIR"
|
||||||
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then
|
|
||||||
python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
echo "patch_disk_images.py not found" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
systemctl restart virtuality-web.service
|
systemctl restart virtuality-web.service
|
||||||
|
|
||||||
echo "Disk image support installed. Open: /disk-images"
|
echo "Disk image support installed. Open: /disk-images"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ VENV_DIR="/opt/virtuality/venv"
|
|||||||
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
|
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
|
||||||
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
|
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
|
||||||
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
|
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
|
||||||
|
BACKUP_SERVICE_FILE="/etc/systemd/system/virtuality-backup.service"
|
||||||
|
BACKUP_TIMER_FILE="/etc/systemd/system/virtuality-backup.timer"
|
||||||
PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||||
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
|
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
|
||||||
LOG_DIR="/var/log/virtuality"
|
LOG_DIR="/var/log/virtuality"
|
||||||
@@ -18,6 +20,10 @@ LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log"
|
|||||||
PROFILE_DIR="/var/lib/virtuality/config"
|
PROFILE_DIR="/var/lib/virtuality/config"
|
||||||
PROFILE_FILE="${PROFILE_DIR}/host_profile.json"
|
PROFILE_FILE="${PROFILE_DIR}/host_profile.json"
|
||||||
SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret"
|
SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret"
|
||||||
|
TLS_DIR="${PROFILE_DIR}/tls"
|
||||||
|
TLS_CERT="${TLS_DIR}/virtuality.crt"
|
||||||
|
TLS_KEY="${TLS_DIR}/virtuality.key"
|
||||||
|
TLS_ENABLED="${VIRTUALITY_TLS:-1}"
|
||||||
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
|
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
|
||||||
TOTAL_STEPS=13
|
TOTAL_STEPS=13
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
@@ -75,16 +81,6 @@ run_logged() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
restore_canonical_templates() {
|
|
||||||
local templates=("_sidebar.html" "dashboard.html" "vm_create.html" "vm_detail.html" "iso.html" "disk_images.html" "operations.html" "operation_detail.html" "host.html" "network.html" "logs.html" "update.html")
|
|
||||||
local name=""
|
|
||||||
for name in "${templates[@]}"; do
|
|
||||||
if [[ -f "${WEB_DIR}/templates/${name}" ]]; then
|
|
||||||
cp "${WEB_DIR}/templates/${name}" "${APP_DIR}/templates/${name}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
|
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
|
||||||
require_root() { [[ "$EUID" -eq 0 ]] || fail "Запусти через sudo: sudo bash scripts/install_web_panel.sh"; }
|
require_root() { [[ "$EUID" -eq 0 ]] || fail "Запусти через sudo: sudo bash scripts/install_web_panel.sh"; }
|
||||||
json_value() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$PROFILE_FILE" "$1" 2>/dev/null || true; }
|
json_value() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$PROFILE_FILE" "$1" 2>/dev/null || true; }
|
||||||
@@ -143,7 +139,7 @@ step "Обновляем apt cache"
|
|||||||
run_logged "apt update выполнен" apt update
|
run_logged "apt update выполнен" apt update
|
||||||
|
|
||||||
step "Устанавливаем системные зависимости под выбранную сборку"
|
step "Устанавливаем системные зависимости под выбранную сборку"
|
||||||
COMMON_PACKAGES=(python3 python3-venv python3-pip rsync openssl curl wget unzip nftables novnc python3-websockify)
|
COMMON_PACKAGES=(python3 python3-venv python3-pip python3-dev rsync openssl curl wget unzip nftables novnc python3-websockify libvirt-dev pkg-config gcc)
|
||||||
X86_PACKAGES=(qemu-system-x86 qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
|
X86_PACKAGES=(qemu-system-x86 qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
|
||||||
ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
|
ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
|
||||||
PACKAGES=("${COMMON_PACKAGES[@]}")
|
PACKAGES=("${COMMON_PACKAGES[@]}")
|
||||||
@@ -167,113 +163,6 @@ fi
|
|||||||
step "Копируем web-панель в /opt/virtuality"
|
step "Копируем web-панель в /opt/virtuality"
|
||||||
run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality
|
run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality
|
||||||
run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete "$WEB_DIR/" "$APP_DIR/"
|
run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete "$WEB_DIR/" "$APP_DIR/"
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_web_console.py" ]]; then
|
|
||||||
run_logged "noVNC web-console patch применён" python3 "${REPO_DIR}/scripts/patch_web_console.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_web_console.py не найден, noVNC console patch пропущен"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_upload_compat.py" ]]; then
|
|
||||||
run_logged "upload compatibility patch применён" python3 "${REPO_DIR}/scripts/patch_upload_compat.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_upload_compat.py не найден, совместимость загрузки файлов пропущена"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" ]]; then
|
|
||||||
run_logged "upload navigation guard patch применён" python3 "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_upload_navigation_guard.py не найден, защита загрузок от переходов пропущена"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then
|
|
||||||
run_logged "disk images patch применён" python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_disk_images.py не найден, менеджер дисковых образов пропущен"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_vm_boot_order.py" ]]; then
|
|
||||||
run_logged "VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_vm_boot_order.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_vm_boot_order.py не найден, порядок загрузки VM пропущен"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" ]]; then
|
|
||||||
run_logged "existing VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_existing_vm_boot_order.py не найден, порядок загрузки существующих VM пропущен"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_resources.py" ]]; then
|
|
||||||
run_logged "existing VM resources patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_resources.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_existing_vm_resources.py не найден, ресурсы существующих VM пропущены"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" ]]; then
|
|
||||||
run_logged "existing VM ISO mount patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_existing_vm_iso_mount.py не найден, монтирование ISO в VM пропущено"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" ]]; then
|
|
||||||
run_logged "VM detail resource layout patch применён" python3 "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_vm_detail_resource_layout.py не найден, раскладка ресурсов VM пропущена"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" ]]; then
|
|
||||||
run_logged "legacy boot order cleanup patch применён" python3 "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_remove_legacy_boot_order_card.py не найден, удаление старого блока порядка загрузки пропущено"
|
|
||||||
fi
|
|
||||||
run_logged "Канонические шаблоны панели восстановлены после VM-патчей" restore_canonical_templates
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_disk_archives.py" ]]; then
|
|
||||||
run_logged "disk archive import patch применён" python3 "${REPO_DIR}/scripts/patch_disk_archives.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_disk_archives.py не найден, импорт архивов дисков пропущен"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" ]]; then
|
|
||||||
run_logged "DHCP leases empty-state patch применён" python3 "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_dhcp_leases_empty.py не найден, диагностика DHCP leases пропущена"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_network_diagnostics.py" ]]; then
|
|
||||||
run_logged "network diagnostics patch применён" python3 "${REPO_DIR}/scripts/patch_network_diagnostics.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_network_diagnostics.py не найден, диагностика сети пропущена"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_network_ranges.py" ]]; then
|
|
||||||
run_logged "network port ranges patch применён" python3 "${REPO_DIR}/scripts/patch_network_ranges.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_network_ranges.py не найден, поддержка диапазонов портов пропущена"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" ]]; then
|
|
||||||
run_logged "network bridge forward patch применён" python3 "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_network_bridge_forwards.py не найден, проброс bridge/static VM пропущен"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_network_nat_errors.py" ]]; then
|
|
||||||
run_logged "network NAT error patch применён" python3 "${REPO_DIR}/scripts/patch_network_nat_errors.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_network_nat_errors.py не найден, безопасные ошибки NAT пропущены"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_logs_center.py" ]]; then
|
|
||||||
run_logged "logs center patch применён" python3 "${REPO_DIR}/scripts/patch_logs_center.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_logs_center.py не найден, центр журналов пропущен"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_vm_network_guard.py" ]]; then
|
|
||||||
run_logged "VM network guard patch применён" python3 "${REPO_DIR}/scripts/patch_vm_network_guard.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_vm_network_guard.py не найден, защита от отсутствующего bridge пропущена"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_update_center.py" ]]; then
|
|
||||||
run_logged "update center patch применён" python3 "${REPO_DIR}/scripts/patch_update_center.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_update_center.py не найден, центр обновлений пропущен"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_live_status.py" ]]; then
|
|
||||||
run_logged "live status patch применён" python3 "${REPO_DIR}/scripts/patch_live_status.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_live_status.py не найден, live-статусы пропущены"
|
|
||||||
fi
|
|
||||||
if [[ -f "${REPO_DIR}/scripts/patch_vm_autostart.py" ]]; then
|
|
||||||
run_logged "VM autostart patch применён" python3 "${REPO_DIR}/scripts/patch_vm_autostart.py" "${APP_DIR}/app.py"
|
|
||||||
else
|
|
||||||
warn "patch_vm_autostart.py не найден, нормализация автозапуска VM пропущена"
|
|
||||||
fi
|
|
||||||
run_logged "Канонические шаблоны панели финально восстановлены" restore_canonical_templates
|
|
||||||
run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR"
|
run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR"
|
||||||
if [[ -f "$PROFILE_FILE" ]]; then
|
if [[ -f "$PROFILE_FILE" ]]; then
|
||||||
ok "Профиль уже сохранён: $PROFILE_FILE"
|
ok "Профиль уже сохранён: $PROFILE_FILE"
|
||||||
@@ -300,6 +189,18 @@ TMP=${UPLOAD_TMP_DIR}
|
|||||||
EOF
|
EOF
|
||||||
chmod 600 "${APP_DIR}/.env"
|
chmod 600 "${APP_DIR}/.env"
|
||||||
ok "Создан ${APP_DIR}/.env"
|
ok "Создан ${APP_DIR}/.env"
|
||||||
|
if [[ "$TLS_ENABLED" == "1" ]]; then
|
||||||
|
if [[ -s "$TLS_CERT" && -s "$TLS_KEY" ]]; then
|
||||||
|
ok "Используем существующий TLS-сертификат: $TLS_CERT"
|
||||||
|
else
|
||||||
|
mkdir -p "$TLS_DIR"
|
||||||
|
run_logged "Самоподписанный TLS-сертификат создан на 10 лет" openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes -keyout "$TLS_KEY" -out "$TLS_CERT" -subj "/CN=virtuality" -addext "subjectAltName=DNS:virtuality,IP:127.0.0.1"
|
||||||
|
chmod 600 "$TLS_KEY"
|
||||||
|
fi
|
||||||
|
ok "Панель будет работать по HTTPS (отключить: VIRTUALITY_TLS=0)"
|
||||||
|
else
|
||||||
|
warn "TLS отключён через VIRTUALITY_TLS=0 — панель будет работать по HTTP"
|
||||||
|
fi
|
||||||
ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
|
ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
|
||||||
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
|
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
|
||||||
|
|
||||||
@@ -313,8 +214,17 @@ fi
|
|||||||
step "Устанавливаем Python-зависимости"
|
step "Устанавливаем Python-зависимости"
|
||||||
run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
|
run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
|
||||||
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
|
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
|
||||||
|
if "$VENV_DIR/bin/pip" install libvirt-python >> "$LOG_FILE" 2>&1; then
|
||||||
|
ok "libvirt-python установлен — панель работает через быстрый libvirt-backend"
|
||||||
|
else
|
||||||
|
warn "libvirt-python не установился — панель будет работать через virsh (медленнее, но полностью функционально)"
|
||||||
|
fi
|
||||||
|
|
||||||
step "Создаём systemd service"
|
step "Создаём systemd service"
|
||||||
|
UVICORN_TLS_ARGS=""
|
||||||
|
if [[ "$TLS_ENABLED" == "1" ]]; then
|
||||||
|
UVICORN_TLS_ARGS=" --ssl-certfile ${TLS_CERT} --ssl-keyfile ${TLS_KEY}"
|
||||||
|
fi
|
||||||
cat > "$SERVICE_FILE" <<EOF
|
cat > "$SERVICE_FILE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Virtuality Web Panel
|
Description=Virtuality Web Panel
|
||||||
@@ -324,7 +234,7 @@ Wants=network-online.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=${APP_DIR}
|
WorkingDirectory=${APP_DIR}
|
||||||
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host 0.0.0.0 --port ${PORT}
|
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host 0.0.0.0 --port ${PORT}${UVICORN_TLS_ARGS}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
User=root
|
User=root
|
||||||
@@ -378,6 +288,33 @@ EOF
|
|||||||
else
|
else
|
||||||
warn "auto_update_check.sh не найден, автообновление пропущено"
|
warn "auto_update_check.sh не найден, автообновление пропущено"
|
||||||
fi
|
fi
|
||||||
|
cat > "$BACKUP_SERVICE_FILE" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Virtuality scheduled VM backups
|
||||||
|
After=libvirtd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/backup_core.py --scheduled
|
||||||
|
EOF
|
||||||
|
cat > "$BACKUP_TIMER_FILE" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Run Virtuality VM backups nightly at 04:30 Moscow time
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 01:30:00 UTC
|
||||||
|
AccuracySec=5min
|
||||||
|
Persistent=true
|
||||||
|
Unit=virtuality-backup.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
run_logged "systemd daemon-reload выполнен для бэкапов" systemctl daemon-reload
|
||||||
|
run_logged "virtuality-backup.timer включён" systemctl enable --now virtuality-backup.timer
|
||||||
|
ok "Ночные бэкапы VM: расписание настраивается на странице /backups"
|
||||||
|
|
||||||
step "Запускаем Virtuality Web Panel"
|
step "Запускаем Virtuality Web Panel"
|
||||||
run_logged "virtuality-web.service включён и запущен" systemctl enable --now virtuality-web.service
|
run_logged "virtuality-web.service включён и запущен" systemctl enable --now virtuality-web.service
|
||||||
@@ -404,13 +341,18 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
SERVER_IP="$(hostname -I | awk '{print $1}')"
|
SERVER_IP="$(hostname -I | awk '{print $1}')"
|
||||||
|
PANEL_SCHEME="http"
|
||||||
|
[[ "$TLS_ENABLED" == "1" ]] && PANEL_SCHEME="https"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||||
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Web Panel завершена${RESET} ${GREEN}${BOLD}│${RESET}"
|
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Web Panel завершена${RESET} ${GREEN}${BOLD}│${RESET}"
|
||||||
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||||
echo
|
echo
|
||||||
echo -e "${BOLD}URL:${RESET} http://${SERVER_IP}:${PORT}"
|
echo -e "${BOLD}URL:${RESET} ${PANEL_SCHEME}://${SERVER_IP}:${PORT}"
|
||||||
|
if [[ "$TLS_ENABLED" == "1" ]]; then
|
||||||
|
echo -e "${DIM}Сертификат самоподписанный — браузер один раз спросит подтверждение.${RESET}"
|
||||||
|
fi
|
||||||
echo -e "${BOLD}Login:${RESET} ${AUTH_USER} / пароль Linux-пользователя"
|
echo -e "${BOLD}Login:${RESET} ${AUTH_USER} / пароль Linux-пользователя"
|
||||||
echo -e "${BOLD}Profile:${RESET} ${LABEL:-$PROFILE}"
|
echo -e "${BOLD}Profile:${RESET} ${LABEL:-$PROFILE}"
|
||||||
echo -e "${BOLD}Arch:${RESET} ${ARCH:-unknown}"
|
echo -e "${BOLD}Arch:${RESET} ${ARCH:-unknown}"
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
helpers = r'''
|
|
||||||
|
|
||||||
def is_x86_host(profile: dict[str, Any]) -> bool:
|
|
||||||
return str(profile.get("arch", "")) in ("x86_64", "amd64")
|
|
||||||
|
|
||||||
|
|
||||||
def is_arm64_guest_on_x86(guest_arch: str, profile: dict[str, Any]) -> bool:
|
|
||||||
return guest_arch == "aarch64" and is_x86_host(profile)
|
|
||||||
|
|
||||||
|
|
||||||
def available_memory_mib() -> int:
|
|
||||||
try:
|
|
||||||
for line in Path('/proc/meminfo').read_text().splitlines():
|
|
||||||
if line.startswith('MemAvailable:'):
|
|
||||||
return int(line.split()[1]) // 1024
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def arm64_emulation_memory_error(memory: int) -> str | None:
|
|
||||||
available = available_memory_mib()
|
|
||||||
requested = int(memory)
|
|
||||||
reserve = 768
|
|
||||||
if available <= 0:
|
|
||||||
return None
|
|
||||||
if requested + reserve <= available:
|
|
||||||
return None
|
|
||||||
recommended = max(512, available - reserve)
|
|
||||||
return (
|
|
||||||
f"Недостаточно RAM для ARM64-эмуляции: запрошено {requested} MB, "
|
|
||||||
f"доступно около {available} MB, нужен запас минимум {reserve} MB. "
|
|
||||||
f"Уменьши RAM VM примерно до {recommended} MB или освободи память на хосте."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def xml_escape(value: str) -> str:
|
|
||||||
return str(value).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
||||||
|
|
||||||
|
|
||||||
def arm64_emulation_xml(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str:
|
|
||||||
if network_mode == "nat":
|
|
||||||
interface_xml = f"""
|
|
||||||
<interface type='network'>
|
|
||||||
<source network='{xml_escape(network_core.NETWORK_NAME)}'/>
|
|
||||||
<model type='virtio'/>
|
|
||||||
</interface>"""
|
|
||||||
else:
|
|
||||||
interface_xml = f"""
|
|
||||||
<interface type='bridge'>
|
|
||||||
<source bridge='{xml_escape(bridge)}'/>
|
|
||||||
<model type='virtio'/>
|
|
||||||
</interface>"""
|
|
||||||
return f"""<domain type='qemu'>
|
|
||||||
<name>{xml_escape(name)}</name>
|
|
||||||
<memory unit='MiB'>{int(memory)}</memory>
|
|
||||||
<currentMemory unit='MiB'>{int(memory)}</currentMemory>
|
|
||||||
<vcpu placement='static'>{int(vcpus)}</vcpu>
|
|
||||||
<os>
|
|
||||||
<type arch='aarch64' machine='virt'>hvm</type>
|
|
||||||
</os>
|
|
||||||
<cpu mode='custom' match='exact'>
|
|
||||||
<model fallback='allow'>cortex-a57</model>
|
|
||||||
</cpu>
|
|
||||||
<features>
|
|
||||||
<gic version='3'/>
|
|
||||||
</features>
|
|
||||||
<clock offset='utc'/>
|
|
||||||
<on_poweroff>destroy</on_poweroff>
|
|
||||||
<on_reboot>restart</on_reboot>
|
|
||||||
<on_crash>restart</on_crash>
|
|
||||||
<devices>
|
|
||||||
<emulator>/usr/bin/qemu-system-aarch64</emulator>
|
|
||||||
<disk type='file' device='disk'>
|
|
||||||
<driver name='qemu' type='qcow2'/>
|
|
||||||
<source file='{xml_escape(str(disk_path))}'/>
|
|
||||||
<target dev='vda' bus='virtio'/>
|
|
||||||
</disk>{interface_xml}
|
|
||||||
<graphics type='vnc' port='-1' autoport='yes' listen='0.0.0.0'>
|
|
||||||
<listen type='address' address='0.0.0.0'/>
|
|
||||||
</graphics>
|
|
||||||
<video>
|
|
||||||
<model type='virtio'/>
|
|
||||||
</video>
|
|
||||||
<console type='pty'>
|
|
||||||
<target type='serial' port='0'/>
|
|
||||||
</console>
|
|
||||||
</devices>
|
|
||||||
</domain>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def make_arm64_emulation_script(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str:
|
|
||||||
xml_path = Path('/tmp') / f"virtuality-{name}-arm64.xml"
|
|
||||||
xml_path.write_text(arm64_emulation_xml(name, memory, vcpus, disk_path, network_mode, bridge))
|
|
||||||
return f"virsh define {xml_path} && virsh start {name}"
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'def arm64_emulation_xml(' not in text:
|
|
||||||
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('valid_vm_name marker not found')
|
|
||||||
text = text.replace(marker, helpers + marker, 1)
|
|
||||||
changed.append('ARM64 emulation XML helpers added')
|
|
||||||
else:
|
|
||||||
if 'def arm64_emulation_memory_error(' not in text:
|
|
||||||
marker = '\n\ndef xml_escape(value: str) -> str:'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('xml_escape marker not found')
|
|
||||||
memory_helpers = r'''
|
|
||||||
|
|
||||||
def available_memory_mib() -> int:
|
|
||||||
try:
|
|
||||||
for line in Path('/proc/meminfo').read_text().splitlines():
|
|
||||||
if line.startswith('MemAvailable:'):
|
|
||||||
return int(line.split()[1]) // 1024
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def arm64_emulation_memory_error(memory: int) -> str | None:
|
|
||||||
available = available_memory_mib()
|
|
||||||
requested = int(memory)
|
|
||||||
reserve = 768
|
|
||||||
if available <= 0:
|
|
||||||
return None
|
|
||||||
if requested + reserve <= available:
|
|
||||||
return None
|
|
||||||
recommended = max(512, available - reserve)
|
|
||||||
return (
|
|
||||||
f"Недостаточно RAM для ARM64-эмуляции: запрошено {requested} MB, "
|
|
||||||
f"доступно около {available} MB, нужен запас минимум {reserve} MB. "
|
|
||||||
f"Уменьши RAM VM примерно до {recommended} MB или освободи память на хосте."
|
|
||||||
)
|
|
||||||
'''
|
|
||||||
text = text.replace(marker, memory_helpers + marker, 1)
|
|
||||||
changed.append('ARM64 memory guard helpers added')
|
|
||||||
else:
|
|
||||||
changed.append('ARM64 memory guard helpers already present')
|
|
||||||
changed.append('ARM64 emulation XML helpers already present')
|
|
||||||
|
|
||||||
old_disk_cmd = ''' if source_type == "disk_image":
|
|
||||||
source_disk = Path(disk_image_path).resolve()
|
|
||||||
source_format = disk_image_format(source_disk)
|
|
||||||
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
|
|
||||||
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
|
|
||||||
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
|
|
||||||
else:
|
|
||||||
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
|
|
||||||
'''
|
|
||||||
new_disk_cmd = ''' if source_type == "disk_image":
|
|
||||||
source_disk = Path(disk_image_path).resolve()
|
|
||||||
source_format = disk_image_format(source_disk)
|
|
||||||
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
|
|
||||||
if is_arm64_guest_on_x86(resolved_guest_arch, profile):
|
|
||||||
memory_error = arm64_emulation_memory_error(memory)
|
|
||||||
if memory_error:
|
|
||||||
return vm_form_context(request, error=memory_error, form=form, status_code=400)
|
|
||||||
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
|
|
||||||
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
|
|
||||||
else:
|
|
||||||
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
|
|
||||||
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
|
|
||||||
else:
|
|
||||||
if is_arm64_guest_on_x86(resolved_guest_arch, profile):
|
|
||||||
return vm_form_context(request, error="ARM64 ISO на x86-хосте пока не поддерживается. Загрузи готовый ARM64 .img/.qcow2 в разделе «Диски» и создай VM из готового диска.", form=form, status_code=400)
|
|
||||||
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
|
|
||||||
'''
|
|
||||||
old_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile):
|
|
||||||
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
|
|
||||||
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
|
|
||||||
else:
|
|
||||||
'''
|
|
||||||
new_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile):
|
|
||||||
memory_error = arm64_emulation_memory_error(memory)
|
|
||||||
if memory_error:
|
|
||||||
return vm_form_context(request, error=memory_error, form=form, status_code=400)
|
|
||||||
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
|
|
||||||
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
|
|
||||||
else:
|
|
||||||
'''
|
|
||||||
if old_disk_cmd in text:
|
|
||||||
text = text.replace(old_disk_cmd, new_disk_cmd, 1)
|
|
||||||
changed.append('ARM64 disk image on x86 uses direct libvirt XML with memory guard')
|
|
||||||
elif old_arm64_xml_cmd in text:
|
|
||||||
text = text.replace(old_arm64_xml_cmd, new_arm64_xml_cmd, 1)
|
|
||||||
changed.append('ARM64 memory guard added to existing XML path')
|
|
||||||
elif 'memory_error = arm64_emulation_memory_error(memory)' in text:
|
|
||||||
changed.append('ARM64 memory guard already present')
|
|
||||||
elif 'make_arm64_emulation_script' in text:
|
|
||||||
changed.append('ARM64 disk image XML path already present')
|
|
||||||
else:
|
|
||||||
raise SystemExit('disk image create command marker not found')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('arm64 emulation xml patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
changed: list[str] = []
|
|
||||||
warnings: list[str] = []
|
|
||||||
|
|
||||||
if not app_path.exists():
|
|
||||||
print(f'WARN: app.py not found: {app_path}')
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
templates_dir = app_path.parent / 'templates'
|
|
||||||
dashboard_template_path = templates_dir / 'dashboard.html'
|
|
||||||
network_template_path = templates_dir / 'network.html'
|
|
||||||
if not dashboard_template_path.exists():
|
|
||||||
print(f'WARN: dashboard.html not found: {dashboard_template_path}')
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
text = app_path.read_text(encoding='utf-8')
|
|
||||||
|
|
||||||
text, removed_helpers = re.subn(
|
|
||||||
r"\n\ndef parse_dhcp_leases_output\(output: str\).*?\n\ndef parse_virsh_list\(\) -> list\[dict\[str, str\]\]:",
|
|
||||||
"\n\ndef parse_virsh_list() -> list[dict[str, str]]:",
|
|
||||||
text,
|
|
||||||
count=1,
|
|
||||||
flags=re.S,
|
|
||||||
)
|
|
||||||
if removed_helpers:
|
|
||||||
changed.append('old VM IP helper block was removed')
|
|
||||||
|
|
||||||
new_parse_virsh_list = '''def parse_virsh_list() -> list[dict[str, str]]:
|
|
||||||
def clean_ip(ip: str) -> str:
|
|
||||||
ip = (ip or "").split("/")[0].strip()
|
|
||||||
if not ip or ip.startswith("127.") or ip.startswith("169.254.") or ip == "0.0.0.0":
|
|
||||||
return ""
|
|
||||||
return ip
|
|
||||||
|
|
||||||
def manual_ip_map() -> dict[str, str]:
|
|
||||||
path = Path("/var/lib/virtuality/network/vm_ips.json")
|
|
||||||
if not path.exists():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return {str(k): clean_ip(str(v)) for k, v in data.items() if clean_ip(str(v))}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def vm_macs(name: str) -> list[str]:
|
|
||||||
try:
|
|
||||||
result = run_cmd(["virsh", "domiflist", name], timeout=8)
|
|
||||||
if not result.get("ok"):
|
|
||||||
return []
|
|
||||||
return [mac.lower() for mac in re.findall(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", result.get("stdout", ""))]
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def ip_from_domifaddr(name: str) -> str:
|
|
||||||
try:
|
|
||||||
result = run_cmd(["virsh", "domifaddr", name], timeout=8)
|
|
||||||
if result.get("ok"):
|
|
||||||
for ip in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})/\\d+", result.get("stdout", "")):
|
|
||||||
ip = clean_ip(ip)
|
|
||||||
if ip:
|
|
||||||
return ip
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def ip_from_network_core(name: str) -> str:
|
|
||||||
try:
|
|
||||||
resolved = network_core.resolve_vm_ip(name)
|
|
||||||
if resolved:
|
|
||||||
return clean_ip(str(resolved))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def ip_from_dnsmasq_leases(macs: list[str]) -> str:
|
|
||||||
if not macs:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
for lease_file in Path("/var/lib/libvirt/dnsmasq").glob("*.leases"):
|
|
||||||
for line in lease_file.read_text(errors="ignore").splitlines():
|
|
||||||
low = line.lower()
|
|
||||||
if not any(mac in low for mac in macs):
|
|
||||||
continue
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) >= 3:
|
|
||||||
ip = clean_ip(parts[2])
|
|
||||||
if ip:
|
|
||||||
return ip
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def ip_from_neighbor_tables(macs: list[str]) -> str:
|
|
||||||
if not macs:
|
|
||||||
return ""
|
|
||||||
commands = [["ip", "neigh", "show"], ["ip", "neigh", "show", "dev", "virbr100"], ["ip", "neigh", "show", "dev", "br0"], ["arp", "-an"]]
|
|
||||||
for cmd in commands:
|
|
||||||
try:
|
|
||||||
result = run_cmd(cmd, timeout=8)
|
|
||||||
if not result.get("ok"):
|
|
||||||
continue
|
|
||||||
for line in result.get("stdout", "").splitlines():
|
|
||||||
low = line.lower()
|
|
||||||
if not any(mac in low for mac in macs):
|
|
||||||
continue
|
|
||||||
for value in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})\\b", line):
|
|
||||||
ip = clean_ip(value)
|
|
||||||
if ip:
|
|
||||||
return ip
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
manual_ips = manual_ip_map()
|
|
||||||
|
|
||||||
def resolve_ip(name: str) -> str:
|
|
||||||
if not name:
|
|
||||||
return "—"
|
|
||||||
if manual_ips.get(name):
|
|
||||||
return manual_ips[name]
|
|
||||||
macs = vm_macs(name)
|
|
||||||
for resolver in (lambda: ip_from_domifaddr(name), lambda: ip_from_network_core(name), lambda: ip_from_dnsmasq_leases(macs), lambda: ip_from_neighbor_tables(macs)):
|
|
||||||
try:
|
|
||||||
ip = resolver()
|
|
||||||
if ip:
|
|
||||||
return ip
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "—"
|
|
||||||
|
|
||||||
result = run_cmd(["virsh", "list", "--all"])
|
|
||||||
rows = []
|
|
||||||
if not result["ok"]:
|
|
||||||
return rows
|
|
||||||
for line in result["stdout"].splitlines()[2:]:
|
|
||||||
parts = line.strip().split(None, 2)
|
|
||||||
if len(parts) == 3:
|
|
||||||
rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})
|
|
||||||
elif len(parts) == 2:
|
|
||||||
rows.append({"id": "-", "name": parts[0], "state": parts[1]})
|
|
||||||
for row in rows:
|
|
||||||
row["ip"] = resolve_ip(row.get("name", ""))
|
|
||||||
row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
|
|
||||||
return rows
|
|
||||||
'''
|
|
||||||
|
|
||||||
pattern = r"def parse_virsh_list\(\) -> list\[dict\[str, str\]\]:.*?\n\ndef parse_pool_list\(\) -> list\[dict\[str, str\]\]:"
|
|
||||||
replacement = new_parse_virsh_list + "\n\ndef parse_pool_list() -> list[dict[str, str]]:"
|
|
||||||
text, replaced = re.subn(pattern, lambda _match: replacement, text, count=1, flags=re.S)
|
|
||||||
if replaced:
|
|
||||||
changed.append('parse_virsh_list was replaced with manual-first VM IP resolver')
|
|
||||||
else:
|
|
||||||
warnings.append('parse_virsh_list block not found, app.py IP injection skipped')
|
|
||||||
|
|
||||||
manual_route = '''
|
|
||||||
|
|
||||||
@app.post("/network/vm-ip/save")
|
|
||||||
def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: str = Form("")):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
if not valid_vm_name(vm_name):
|
|
||||||
return RedirectResponse(url="/network", status_code=303)
|
|
||||||
manual_ip = (manual_ip or "").strip()
|
|
||||||
if manual_ip and not re.fullmatch(r"(25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}", manual_ip):
|
|
||||||
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400)
|
|
||||||
path = Path("/var/lib/virtuality/network/vm_ips.json")
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
data = {}
|
|
||||||
except Exception:
|
|
||||||
data = {}
|
|
||||||
if manual_ip:
|
|
||||||
data[vm_name] = manual_ip
|
|
||||||
else:
|
|
||||||
data.pop(vm_name, None)
|
|
||||||
tmp = path.with_suffix(".json.tmp")
|
|
||||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
||||||
tmp.replace(path)
|
|
||||||
return RedirectResponse(url="/network", status_code=303)
|
|
||||||
'''
|
|
||||||
|
|
||||||
if '/network/vm-ip/save' not in text:
|
|
||||||
marker = '\n\n@app.post("/network/nat/setup")'
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, manual_route + marker, 1)
|
|
||||||
changed.append('manual VM IP route was added')
|
|
||||||
else:
|
|
||||||
warnings.append('network NAT route marker not found, manual IP route skipped')
|
|
||||||
else:
|
|
||||||
changed.append('manual VM IP route was already present')
|
|
||||||
|
|
||||||
app_path.write_text(text, encoding='utf-8')
|
|
||||||
|
|
||||||
dashboard_html = dashboard_template_path.read_text(encoding='utf-8')
|
|
||||||
header_old = '<tr><th>ID</th><th>Name</th><th>State</th><th>Actions</th></tr>'
|
|
||||||
header_new = '<tr><th>ID</th><th>Name</th><th>IP</th><th>State</th><th>Actions</th></tr>'
|
|
||||||
name_cell = '''<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
|
|
||||||
<td><span'''
|
|
||||||
name_ip_cell = '''<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
|
|
||||||
<td class="strong">{{ vm.ip|default("—") }}</td>
|
|
||||||
<td><span'''
|
|
||||||
if '<th>IP</th>' not in dashboard_html:
|
|
||||||
dashboard_html = dashboard_html.replace(header_old, header_new).replace(name_cell, name_ip_cell).replace('colspan="4" class="muted">Виртуальных машин пока нет', 'colspan="5" class="muted">Виртуальных машин пока нет')
|
|
||||||
changed.append('Dashboard VM IP column was added')
|
|
||||||
elif 'vm.ip' not in dashboard_html:
|
|
||||||
dashboard_html = dashboard_html.replace(name_cell, name_ip_cell)
|
|
||||||
changed.append('Dashboard VM IP cell was added')
|
|
||||||
else:
|
|
||||||
changed.append('Dashboard VM IP column was already present')
|
|
||||||
dashboard_template_path.write_text(dashboard_html, encoding='utf-8')
|
|
||||||
|
|
||||||
if network_template_path.exists():
|
|
||||||
network_html = network_template_path.read_text(encoding='utf-8')
|
|
||||||
manual_card = '''
|
|
||||||
<article class="card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h2>Ручные IP VM</h2>
|
|
||||||
<span class="pill">override</span>
|
|
||||||
</div>
|
|
||||||
<div class="notice">Если VM в bridge/static-сети и IP не виден через DHCP/ARP, укажи адрес вручную. Этот IP будет первым источником для таблицы и проброса портов.</div>
|
|
||||||
<table class="top-space">
|
|
||||||
<thead><tr><th>VM</th><th>Текущий IP</th><th>Ручной IP</th><th>Действие</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for vm in vms %}
|
|
||||||
<tr>
|
|
||||||
<td class="strong">{{ vm.name }}</td>
|
|
||||||
<td>{{ vm.ip|default("—") }}</td>
|
|
||||||
<td>
|
|
||||||
<form method="post" action="/network/vm-ip/save" class="inline-form">
|
|
||||||
<input type="hidden" name="vm_name" value="{{ vm.name }}">
|
|
||||||
<input type="text" name="manual_ip" value="{{ vm.manual_ip|default("") }}" placeholder="например 10.0.0.50" pattern="(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])){3}">
|
|
||||||
</td>
|
|
||||||
<td><button>Сохранить</button></form></td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr><td colspan="4" class="muted">VM пока нет</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</article>
|
|
||||||
'''
|
|
||||||
if 'Ручные IP VM' not in network_html:
|
|
||||||
marker = ' <article class="card">\n <div class="card-head">\n <h2>Диагностика публичного доступа</h2>'
|
|
||||||
if marker in network_html:
|
|
||||||
network_html = network_html.replace(marker, manual_card + '\n' + marker, 1)
|
|
||||||
changed.append('manual VM IP card was added to network page')
|
|
||||||
else:
|
|
||||||
warnings.append('network diagnostics card marker not found, manual IP card skipped')
|
|
||||||
else:
|
|
||||||
changed.append('manual VM IP card was already present')
|
|
||||||
network_template_path.write_text(network_html, encoding='utf-8')
|
|
||||||
else:
|
|
||||||
warnings.append('network.html not found, manual IP card skipped')
|
|
||||||
|
|
||||||
print('DHCP leases empty-state patch completed:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
for item in warnings:
|
|
||||||
print(f'WARN: {item}')
|
|
||||||
|
|
||||||
raise SystemExit(0)
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
if 'import zipfile' not in text:
|
|
||||||
text = text.replace('import uuid\n', 'import uuid\nimport lzma\nimport tarfile\nimport zipfile\n', 1)
|
|
||||||
changed.append('archive imports added')
|
|
||||||
elif 'import lzma' not in text:
|
|
||||||
text = text.replace('import tarfile\n', 'import lzma\nimport tarfile\n', 1) if 'import tarfile\n' in text else text.replace('import zipfile\n', 'import lzma\nimport zipfile\n', 1)
|
|
||||||
changed.append('lzma import added')
|
|
||||||
else:
|
|
||||||
changed.append('archive imports already present')
|
|
||||||
|
|
||||||
helpers = r'''
|
|
||||||
|
|
||||||
def safe_disk_upload_filename(filename: str) -> str | None:
|
|
||||||
original = Path(filename or "").name.strip()
|
|
||||||
lower = original.lower()
|
|
||||||
if lower.endswith(".tar.gz"):
|
|
||||||
suffix = ".tar.gz"
|
|
||||||
stem = original[:-7]
|
|
||||||
elif lower.endswith(".img.xz"):
|
|
||||||
suffix = ".img.xz"
|
|
||||||
stem = original[:-7]
|
|
||||||
elif lower.endswith(".tgz"):
|
|
||||||
suffix = ".tgz"
|
|
||||||
stem = original[:-4]
|
|
||||||
else:
|
|
||||||
suffix = Path(original).suffix.lower()
|
|
||||||
stem = Path(original).stem
|
|
||||||
if suffix not in (".img", ".raw", ".qcow2", ".img.xz", ".zip", ".tar.gz", ".tgz"):
|
|
||||||
return None
|
|
||||||
stem = re.sub(r"\s+", "-", stem.strip())
|
|
||||||
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
|
|
||||||
stem = stem.strip("._-")[:120]
|
|
||||||
if not stem:
|
|
||||||
stem = "virtuality-disk"
|
|
||||||
return f"{stem}{suffix}"
|
|
||||||
|
|
||||||
|
|
||||||
def disk_upload_is_archive(name: str) -> bool:
|
|
||||||
lower = str(name or "").lower()
|
|
||||||
return lower.endswith((".zip", ".tar.gz", ".tgz"))
|
|
||||||
|
|
||||||
|
|
||||||
def disk_upload_is_xz_image(name: str) -> bool:
|
|
||||||
return str(name or "").lower().endswith(".img.xz")
|
|
||||||
|
|
||||||
|
|
||||||
def disk_upload_is_image(name: str) -> bool:
|
|
||||||
lower = str(name or "").lower()
|
|
||||||
return lower.endswith((".img", ".raw", ".qcow2", ".img.xz"))
|
|
||||||
|
|
||||||
|
|
||||||
def archive_member_is_safe(name: str) -> bool:
|
|
||||||
if not name:
|
|
||||||
return False
|
|
||||||
p = Path(name)
|
|
||||||
if p.is_absolute():
|
|
||||||
return False
|
|
||||||
return ".." not in p.parts
|
|
||||||
|
|
||||||
|
|
||||||
def archive_member_basename(name: str) -> str | None:
|
|
||||||
return safe_disk_image_filename(Path(name).name)
|
|
||||||
|
|
||||||
|
|
||||||
def unique_disk_image_path(name: str) -> Path:
|
|
||||||
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
safe = safe_disk_image_filename(name)
|
|
||||||
if not safe:
|
|
||||||
raise ValueError("Некорректное имя образа диска")
|
|
||||||
target = DISK_IMAGES_DIR / safe
|
|
||||||
if not target.exists():
|
|
||||||
return target
|
|
||||||
stem = target.stem
|
|
||||||
suffix = target.suffix
|
|
||||||
for index in range(1, 1000):
|
|
||||||
candidate = DISK_IMAGES_DIR / f"{stem}-{index}{suffix}"
|
|
||||||
if not candidate.exists():
|
|
||||||
return candidate
|
|
||||||
raise ValueError("Не удалось подобрать свободное имя файла")
|
|
||||||
|
|
||||||
|
|
||||||
def find_archive_disk_members(archive_path: Path) -> list[dict[str, Any]]:
|
|
||||||
members: list[dict[str, Any]] = []
|
|
||||||
lower = archive_path.name.lower()
|
|
||||||
if lower.endswith(".zip"):
|
|
||||||
with zipfile.ZipFile(archive_path) as archive:
|
|
||||||
for info in archive.infolist():
|
|
||||||
if info.is_dir() or not archive_member_is_safe(info.filename):
|
|
||||||
continue
|
|
||||||
safe_name = archive_member_basename(info.filename)
|
|
||||||
if safe_name:
|
|
||||||
members.append({"kind": "zip", "name": info.filename, "safe_name": safe_name, "size": int(info.file_size)})
|
|
||||||
elif lower.endswith((".tar.gz", ".tgz")):
|
|
||||||
with tarfile.open(archive_path, "r:gz") as archive:
|
|
||||||
for info in archive.getmembers():
|
|
||||||
if not info.isfile() or not archive_member_is_safe(info.name):
|
|
||||||
continue
|
|
||||||
safe_name = archive_member_basename(info.name)
|
|
||||||
if safe_name:
|
|
||||||
members.append({"kind": "tar", "name": info.name, "safe_name": safe_name, "size": int(info.size)})
|
|
||||||
else:
|
|
||||||
raise ValueError("Поддерживаются только .zip, .tar.gz и .tgz")
|
|
||||||
return sorted(members, key=lambda item: item.get("size", 0), reverse=True)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_disk_archive(archive_path: Path) -> list[Path]:
|
|
||||||
members = find_archive_disk_members(archive_path)
|
|
||||||
if not members:
|
|
||||||
raise ValueError("В архиве не найдено .img, .raw или .qcow2 файлов")
|
|
||||||
extracted: list[Path] = []
|
|
||||||
lower = archive_path.name.lower()
|
|
||||||
if lower.endswith(".zip"):
|
|
||||||
with zipfile.ZipFile(archive_path) as archive:
|
|
||||||
for member in members:
|
|
||||||
target = unique_disk_image_path(member["safe_name"])
|
|
||||||
with archive.open(member["name"], "r") as src, target.open("wb") as dst:
|
|
||||||
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
|
||||||
extracted.append(target)
|
|
||||||
else:
|
|
||||||
with tarfile.open(archive_path, "r:gz") as archive:
|
|
||||||
for member in members:
|
|
||||||
file_obj = archive.extractfile(member["name"])
|
|
||||||
if file_obj is None:
|
|
||||||
continue
|
|
||||||
target = unique_disk_image_path(member["safe_name"])
|
|
||||||
with file_obj as src, target.open("wb") as dst:
|
|
||||||
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
|
||||||
extracted.append(target)
|
|
||||||
return extracted
|
|
||||||
|
|
||||||
|
|
||||||
def extract_xz_disk_image(compressed_path: Path, safe_name: str | None = None) -> Path:
|
|
||||||
source_name = safe_name or compressed_path.name
|
|
||||||
if not source_name.lower().endswith(".img.xz"):
|
|
||||||
raise ValueError("Поддерживаются только сжатые образы .img.xz")
|
|
||||||
raw_name = source_name[:-3]
|
|
||||||
target = unique_disk_image_path(raw_name)
|
|
||||||
try:
|
|
||||||
with lzma.open(compressed_path, "rb") as src, target.open("wb") as dst:
|
|
||||||
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
|
||||||
except Exception:
|
|
||||||
target.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def disk_convert_target_path(source_path: Path) -> Path:
|
|
||||||
if source_path.suffix.lower() == ".qcow2":
|
|
||||||
return source_path
|
|
||||||
target = source_path.with_suffix(".qcow2")
|
|
||||||
if not target.exists():
|
|
||||||
return target
|
|
||||||
for index in range(1, 1000):
|
|
||||||
candidate = source_path.with_name(f"{source_path.stem}-{index}.qcow2")
|
|
||||||
if not candidate.exists():
|
|
||||||
return candidate
|
|
||||||
raise ValueError("Не удалось подобрать имя qcow2 для конвертации")
|
|
||||||
|
|
||||||
|
|
||||||
def disk_convert_progress(line: str, current: int) -> int:
|
|
||||||
match = re.search(r"\((\d+(?:\.\d+)?)/100%\)", line or "")
|
|
||||||
if match:
|
|
||||||
return max(current, int(float(match.group(1))))
|
|
||||||
match = re.search(r"(\d+(?:\.\d+)?)%", line or "")
|
|
||||||
if match:
|
|
||||||
return max(current, int(float(match.group(1))))
|
|
||||||
return current
|
|
||||||
|
|
||||||
|
|
||||||
def run_disk_convert_worker(operation_id: str, source_path: str, target_path: str) -> None:
|
|
||||||
operation = read_operation(operation_id)
|
|
||||||
if not operation:
|
|
||||||
return
|
|
||||||
source = Path(source_path)
|
|
||||||
target = Path(target_path)
|
|
||||||
update_operation(operation, status="running", progress=1, message=f"Конвертация {source.name} в qcow2", started_at=utc_now())
|
|
||||||
cmd = ["qemu-img", "convert", "-p", "-f", disk_image_format(source), "-O", "qcow2", str(source), str(target)]
|
|
||||||
append_operation_log(operation_id, "Запуск конвертации:")
|
|
||||||
append_operation_log(operation_id, " ".join(cmd))
|
|
||||||
try:
|
|
||||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
|
||||||
progress = 1
|
|
||||||
if process.stdout:
|
|
||||||
for line in process.stdout:
|
|
||||||
append_operation_log(operation_id, line)
|
|
||||||
progress = disk_convert_progress(line, progress)
|
|
||||||
fresh = read_operation(operation_id) or operation
|
|
||||||
update_operation(fresh, progress=progress, message=f"Конвертация {source.name}: {progress}%")
|
|
||||||
exit_code = process.wait()
|
|
||||||
fresh = read_operation(operation_id) or operation
|
|
||||||
if exit_code == 0:
|
|
||||||
append_operation_log(operation_id, f"Конвертация завершена: {target}")
|
|
||||||
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=f"Готово: {target.name}", finished_at=utc_now(), target_path=str(target))
|
|
||||||
else:
|
|
||||||
append_operation_log(operation_id, f"qemu-img завершился с ошибкой. Exit code: {exit_code}")
|
|
||||||
target.unlink(missing_ok=True)
|
|
||||||
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"qemu-img завершился с ошибкой: {exit_code}", finished_at=utc_now())
|
|
||||||
except Exception as exc:
|
|
||||||
fresh = read_operation(operation_id) or operation
|
|
||||||
append_operation_log(operation_id, f"Ошибка конвертации: {exc}")
|
|
||||||
target.unlink(missing_ok=True)
|
|
||||||
update_operation(fresh, status="error", progress=100, exit_code=-1, message=str(exc), finished_at=utc_now())
|
|
||||||
|
|
||||||
|
|
||||||
def start_disk_convert_operation(source_path: Path) -> dict[str, Any] | None:
|
|
||||||
if source_path.suffix.lower() == ".qcow2":
|
|
||||||
return None
|
|
||||||
target_path = disk_convert_target_path(source_path)
|
|
||||||
operation_id = str(uuid.uuid4())
|
|
||||||
operation = {
|
|
||||||
"id": operation_id,
|
|
||||||
"type": "disk_convert",
|
|
||||||
"title": f"Конвертация {source_path.name}",
|
|
||||||
"status": "queued",
|
|
||||||
"progress": 0,
|
|
||||||
"message": "Конвертация поставлена в очередь",
|
|
||||||
"created_at": utc_now(),
|
|
||||||
"updated_at": utc_now(),
|
|
||||||
"created_by": AUTH_USER,
|
|
||||||
"source_path": str(source_path),
|
|
||||||
"target_path": str(target_path),
|
|
||||||
}
|
|
||||||
write_operation(operation)
|
|
||||||
append_operation_log(operation_id, "Операция поставлена в очередь.")
|
|
||||||
threading.Thread(target=run_disk_convert_worker, args=(operation_id, str(source_path), str(target_path)), daemon=True).start()
|
|
||||||
return operation
|
|
||||||
|
|
||||||
|
|
||||||
def disk_upload_response(request: Request, payload: dict[str, Any]):
|
|
||||||
if request.headers.get("x-requested-with") == "XMLHttpRequest" or "application/json" in request.headers.get("accept", ""):
|
|
||||||
return JSONResponse(payload)
|
|
||||||
return RedirectResponse(url="/disk-images", status_code=303)
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'def safe_disk_upload_filename(' not in text:
|
|
||||||
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
|
|
||||||
if marker not in text:
|
|
||||||
marker = '\n\ndef bridge_exists(name: str) -> bool:'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('helper insert marker not found')
|
|
||||||
text = text.replace(marker, helpers + marker, 1)
|
|
||||||
changed.append('disk archive helpers added')
|
|
||||||
else:
|
|
||||||
pattern = r"\n\ndef safe_disk_upload_filename\(filename: str\).*?\n\ndef disk_upload_response\(request: Request, payload: dict\[str, Any\]\):.*?\n return RedirectResponse\(url=\"/disk-images\", status_code=303\)\n"
|
|
||||||
text, count = re.subn(pattern, helpers, text, count=1, flags=re.S)
|
|
||||||
changed.append('disk archive helpers replaced with img.xz support' if count else 'disk archive helpers already present')
|
|
||||||
|
|
||||||
old_route_start = text.find('@app.post("/disk-images/upload"')
|
|
||||||
if old_route_start == -1:
|
|
||||||
raise SystemExit('disk image upload route not found')
|
|
||||||
next_route = text.find('\n\n@app.post("/disk-images/{name}/delete")', old_route_start)
|
|
||||||
if next_route == -1:
|
|
||||||
raise SystemExit('disk image delete route marker not found')
|
|
||||||
new_route = r'''@app.post("/disk-images/upload", response_class=HTMLResponse)
|
|
||||||
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
safe_name = safe_disk_upload_filename(image_file.filename or "")
|
|
||||||
if not safe_name:
|
|
||||||
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400)
|
|
||||||
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
|
|
||||||
saved_paths: list[Path] = []
|
|
||||||
try:
|
|
||||||
with tmp_target.open("wb") as out:
|
|
||||||
shutil.copyfileobj(image_file.file, out, length=1024 * 1024)
|
|
||||||
if disk_upload_is_archive(safe_name):
|
|
||||||
saved_paths = extract_disk_archive(tmp_target)
|
|
||||||
tmp_target.unlink(missing_ok=True)
|
|
||||||
elif disk_upload_is_xz_image(safe_name):
|
|
||||||
saved_paths = [extract_xz_disk_image(tmp_target, safe_name)]
|
|
||||||
tmp_target.unlink(missing_ok=True)
|
|
||||||
else:
|
|
||||||
target = unique_disk_image_path(safe_name)
|
|
||||||
tmp_target.rename(target)
|
|
||||||
saved_paths = [target]
|
|
||||||
except Exception as exc:
|
|
||||||
tmp_target.unlink(missing_ok=True)
|
|
||||||
for path in saved_paths:
|
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500)
|
|
||||||
|
|
||||||
operations = []
|
|
||||||
for path in saved_paths:
|
|
||||||
operation = start_disk_convert_operation(path)
|
|
||||||
if operation:
|
|
||||||
operations.append(operation)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"ok": True,
|
|
||||||
"mode": "converting" if operations else "ready",
|
|
||||||
"operation_id": operations[0]["id"] if operations else None,
|
|
||||||
"operation_ids": [op["id"] for op in operations],
|
|
||||||
"files": [path.name for path in saved_paths],
|
|
||||||
"message": f"Загружено файлов: {len(saved_paths)}. Конвертаций запущено: {len(operations)}.",
|
|
||||||
}
|
|
||||||
return disk_upload_response(request, payload)
|
|
||||||
'''
|
|
||||||
text = text[:old_route_start] + new_route + text[next_route:]
|
|
||||||
changed.append('disk image upload route supports img.xz and archives')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('disk archive import patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
print(f'WARN: app.py not found: {app_path}')
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
old_progress = '''def progress_from_line(current: int, line: str) -> int:
|
|
||||||
low = line.lower()
|
|
||||||
if "allocating" in low or "creating storage" in low:
|
|
||||||
return max(current, 30)
|
|
||||||
if "starting install" in low or "installing" in low:
|
|
||||||
return max(current, 50)
|
|
||||||
if "creating domain" in low:
|
|
||||||
return max(current, 75)
|
|
||||||
if "domain creation completed" in low or "installation continues" in low:
|
|
||||||
return max(current, 90)
|
|
||||||
return current
|
|
||||||
'''
|
|
||||||
new_progress = '''def progress_from_line(current: int, line: str) -> int:
|
|
||||||
low = line.lower()
|
|
||||||
convert_match = re.search(r"\\((\\d+(?:\\.\\d+)?)\\s*/\\s*100%\\)", line)
|
|
||||||
if convert_match:
|
|
||||||
return max(1, min(99, int(float(convert_match.group(1)))))
|
|
||||||
percent_match = re.search(r"(\\d+(?:\\.\\d+)?)%", line)
|
|
||||||
if "qemu-img" in low and percent_match:
|
|
||||||
return max(1, min(99, int(float(percent_match.group(1)))))
|
|
||||||
if "allocating" in low or "creating storage" in low:
|
|
||||||
return max(current, 30)
|
|
||||||
if "starting install" in low or "installing" in low:
|
|
||||||
return max(current, 50)
|
|
||||||
if "creating domain" in low:
|
|
||||||
return max(current, 75)
|
|
||||||
if "domain creation completed" in low or "installation continues" in low:
|
|
||||||
return max(current, 90)
|
|
||||||
return current
|
|
||||||
'''
|
|
||||||
if old_progress in text:
|
|
||||||
text = text.replace(old_progress, new_progress, 1)
|
|
||||||
changed.append('qemu-img percent parser added')
|
|
||||||
elif 'convert_match = re.search' in text:
|
|
||||||
changed.append('qemu-img percent parser already present')
|
|
||||||
else:
|
|
||||||
warnings.append('progress_from_line marker not found, progress parser skipped')
|
|
||||||
|
|
||||||
helpers = r'''
|
|
||||||
|
|
||||||
def qemu_img_format_for_path(path: Path) -> str:
|
|
||||||
suffix = path.suffix.lower()
|
|
||||||
if suffix == ".qcow2":
|
|
||||||
return "qcow2"
|
|
||||||
return "raw"
|
|
||||||
|
|
||||||
|
|
||||||
def qcow2_name_for_upload(safe_name: str) -> str:
|
|
||||||
base = Path(safe_name).stem
|
|
||||||
return f"{base}.qcow2"
|
|
||||||
|
|
||||||
|
|
||||||
def start_disk_image_convert_operation(source_path: Path, target_path: Path, source_format: str) -> dict[str, Any]:
|
|
||||||
operation_id = str(uuid.uuid4())
|
|
||||||
cmd = ["qemu-img", "convert", "-p", "-f", source_format, "-O", "qcow2", str(source_path), str(target_path)]
|
|
||||||
operation = {
|
|
||||||
"id": operation_id,
|
|
||||||
"type": "disk_image_convert",
|
|
||||||
"title": f"Конвертация образа {source_path.name}",
|
|
||||||
"status": "queued",
|
|
||||||
"progress": 0,
|
|
||||||
"message": "Ожидание конвертации образа",
|
|
||||||
"created_at": utc_now(),
|
|
||||||
"updated_at": utc_now(),
|
|
||||||
"created_by": AUTH_USER,
|
|
||||||
"source_path": str(source_path),
|
|
||||||
"target_path": str(target_path),
|
|
||||||
"source_format": source_format,
|
|
||||||
"cmd": " ".join(cmd),
|
|
||||||
}
|
|
||||||
start_background_operation(operation, cmd)
|
|
||||||
return operation
|
|
||||||
'''
|
|
||||||
if 'def start_disk_image_convert_operation(' not in text:
|
|
||||||
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, helpers + marker, 1)
|
|
||||||
changed.append('disk conversion operation helpers added')
|
|
||||||
else:
|
|
||||||
warnings.append('valid_vm_name marker not found, conversion helpers skipped')
|
|
||||||
else:
|
|
||||||
changed.append('disk conversion operation helpers already present')
|
|
||||||
|
|
||||||
old_upload = r'''@app.post("/disk-images/upload", response_class=HTMLResponse)
|
|
||||||
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
safe_name = safe_disk_image_filename(image_file.filename or "")
|
|
||||||
if not safe_name:
|
|
||||||
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем."}, status_code=400)
|
|
||||||
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
target = DISK_IMAGES_DIR / safe_name
|
|
||||||
if target.exists():
|
|
||||||
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Образ уже существует: {safe_name}"}, status_code=400)
|
|
||||||
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
|
|
||||||
try:
|
|
||||||
with tmp_target.open("wb") as out:
|
|
||||||
shutil.copyfileobj(image_file.file, out)
|
|
||||||
tmp_target.rename(target)
|
|
||||||
except Exception as exc:
|
|
||||||
tmp_target.unlink(missing_ok=True)
|
|
||||||
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки образа: {exc}"}, status_code=500)
|
|
||||||
return RedirectResponse(url="/disk-images", status_code=303)
|
|
||||||
'''
|
|
||||||
new_upload = r'''@app.post("/disk-images/upload")
|
|
||||||
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
safe_name = safe_disk_image_filename(image_file.filename or "")
|
|
||||||
wants_json = "application/json" in request.headers.get("accept", "") or request.headers.get("x-requested-with") == "XMLHttpRequest"
|
|
||||||
|
|
||||||
def disk_error(message: str, status_code: int = 400):
|
|
||||||
if wants_json:
|
|
||||||
return JSONResponse({"ok": False, "error": message}, status_code=status_code)
|
|
||||||
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": message}, status_code=status_code)
|
|
||||||
|
|
||||||
if not safe_name:
|
|
||||||
return disk_error("Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем.")
|
|
||||||
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
target = DISK_IMAGES_DIR / safe_name
|
|
||||||
final_qcow2 = DISK_IMAGES_DIR / qcow2_name_for_upload(safe_name)
|
|
||||||
if target.exists() or final_qcow2.exists():
|
|
||||||
return disk_error(f"Образ уже существует: {safe_name}")
|
|
||||||
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
|
|
||||||
try:
|
|
||||||
with tmp_target.open("wb") as out:
|
|
||||||
shutil.copyfileobj(image_file.file, out)
|
|
||||||
tmp_target.rename(target)
|
|
||||||
if target.suffix.lower() == ".qcow2":
|
|
||||||
payload = {"ok": True, "mode": "ready", "name": target.name, "redirect": "/disk-images"}
|
|
||||||
else:
|
|
||||||
operation = start_disk_image_convert_operation(target, final_qcow2, qemu_img_format_for_path(target))
|
|
||||||
payload = {"ok": True, "mode": "converting", "name": target.name, "target": final_qcow2.name, "operation_id": operation["id"], "redirect": "/disk-images"}
|
|
||||||
except Exception as exc:
|
|
||||||
tmp_target.unlink(missing_ok=True)
|
|
||||||
return disk_error(f"Ошибка загрузки образа: {exc}", status_code=500)
|
|
||||||
|
|
||||||
if wants_json:
|
|
||||||
return JSONResponse(payload)
|
|
||||||
return RedirectResponse(url="/disk-images", status_code=303)
|
|
||||||
'''
|
|
||||||
if old_upload in text:
|
|
||||||
text = text.replace(old_upload, new_upload, 1)
|
|
||||||
changed.append('disk upload returns JSON and starts conversion operation')
|
|
||||||
elif 'mode": "converting"' in text and ('start_disk_image_convert_operation' in text or 'start_disk_convert_operation' in text):
|
|
||||||
changed.append('disk upload conversion already present')
|
|
||||||
else:
|
|
||||||
warnings.append('disk_image_upload marker not found, upload conversion patch skipped')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('disk convert progress patch completed:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
for item in warnings:
|
|
||||||
print(f'WARN: {item}')
|
|
||||||
raise SystemExit(0)
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
if 'DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")' not in text:
|
|
||||||
text = text.replace('IMAGES_DIR = Path("/var/lib/virtuality/images")\n', 'IMAGES_DIR = Path("/var/lib/virtuality/images")\nDISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")\n', 1)
|
|
||||||
changed.append('DISK_IMAGES_DIR added')
|
|
||||||
else:
|
|
||||||
changed.append('DISK_IMAGES_DIR already present')
|
|
||||||
|
|
||||||
helpers = r'''
|
|
||||||
|
|
||||||
def vm_arch_options() -> list[dict[str, str]]:
|
|
||||||
return [
|
|
||||||
{"value": "auto", "label": "Auto — по профилю хоста"},
|
|
||||||
{"value": "x86_64", "label": "x86_64 / amd64"},
|
|
||||||
{"value": "aarch64", "label": "ARM64 / aarch64"},
|
|
||||||
{"value": "generic", "label": "Generic / no arch override"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_guest_arch(value: str, profile: dict[str, Any]) -> str:
|
|
||||||
value = (value or "auto").strip()
|
|
||||||
if value == "auto":
|
|
||||||
return str(profile.get("recommended_guest_arch") or "x86_64")
|
|
||||||
if value in ("x86_64", "amd64"):
|
|
||||||
return "x86_64"
|
|
||||||
if value in ("aarch64", "arm64"):
|
|
||||||
return "aarch64"
|
|
||||||
if value == "generic":
|
|
||||||
return "generic"
|
|
||||||
return str(profile.get("recommended_guest_arch") or "x86_64")
|
|
||||||
|
|
||||||
|
|
||||||
def list_disk_image_files() -> list[dict[str, str]]:
|
|
||||||
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
files = []
|
|
||||||
for item in sorted(list(DISK_IMAGES_DIR.glob("*.img")) + list(DISK_IMAGES_DIR.glob("*.raw")) + list(DISK_IMAGES_DIR.glob("*.qcow2"))):
|
|
||||||
try:
|
|
||||||
stat = item.stat()
|
|
||||||
size_gb = stat.st_size / 1024 / 1024 / 1024
|
|
||||||
updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
||||||
except OSError:
|
|
||||||
size_gb = 0
|
|
||||||
updated = "unknown"
|
|
||||||
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated})
|
|
||||||
return files
|
|
||||||
|
|
||||||
|
|
||||||
def safe_disk_image_filename(filename: str) -> str | None:
|
|
||||||
name = Path(filename or "").name.strip().replace(" ", "-")
|
|
||||||
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
|
|
||||||
if not name.lower().endswith((".img", ".raw", ".qcow2")):
|
|
||||||
return None
|
|
||||||
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(img|raw|qcow2)", name, re.IGNORECASE):
|
|
||||||
return None
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def disk_image_path_by_name(name: str) -> Path | None:
|
|
||||||
safe_name = safe_disk_image_filename(name)
|
|
||||||
if not safe_name:
|
|
||||||
return None
|
|
||||||
path = (DISK_IMAGES_DIR / safe_name).resolve()
|
|
||||||
if DISK_IMAGES_DIR.resolve() not in path.parents:
|
|
||||||
return None
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def disk_image_format(path: Path) -> str:
|
|
||||||
suffix = path.suffix.lower().lstrip('.')
|
|
||||||
if suffix == 'qcow2':
|
|
||||||
return 'qcow2'
|
|
||||||
return 'raw'
|
|
||||||
|
|
||||||
|
|
||||||
def bridge_exists(name: str) -> bool:
|
|
||||||
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
|
|
||||||
return False
|
|
||||||
return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'def list_disk_image_files() -> list[dict[str, str]]:' not in text:
|
|
||||||
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('valid_vm_name marker not found')
|
|
||||||
text = text.replace(marker, helpers + marker, 1)
|
|
||||||
changed.append('disk image and architecture helpers added')
|
|
||||||
else:
|
|
||||||
if 'def vm_arch_options()' not in text:
|
|
||||||
insert = helpers.split('\n\ndef list_disk_image_files()', 1)[0]
|
|
||||||
marker = '\n\ndef list_disk_image_files() -> list[dict[str, str]]:'
|
|
||||||
text = text.replace(marker, insert + marker, 1)
|
|
||||||
changed.append('architecture helpers added')
|
|
||||||
else:
|
|
||||||
changed.append('architecture helpers already present')
|
|
||||||
changed.append('disk image helpers already present')
|
|
||||||
|
|
||||||
text = text.replace('\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n', '\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n')
|
|
||||||
|
|
||||||
old_context = '"isos": list_iso_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
|
|
||||||
new_context = '"isos": list_iso_files(), "disk_images": list_disk_image_files(), "arch_options": vm_arch_options(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
|
|
||||||
if old_context in text:
|
|
||||||
text = text.replace(old_context, new_context, 1)
|
|
||||||
changed.append('vm form context gets disk images and arch options')
|
|
||||||
elif '"disk_images": list_disk_image_files()' in text and '"arch_options": vm_arch_options()' not in text:
|
|
||||||
text = text.replace('"disk_images": list_disk_image_files(),', '"disk_images": list_disk_image_files(), "arch_options": vm_arch_options(),')
|
|
||||||
text = text.replace('"source_type": "iso", "network_mode"', '"source_type": "iso", "guest_arch": "auto", "network_mode"')
|
|
||||||
changed.append('vm form context upgraded with arch options')
|
|
||||||
elif '"arch_options": vm_arch_options()' in text:
|
|
||||||
changed.append('vm form context already has arch options')
|
|
||||||
else:
|
|
||||||
warnings.append('vm_form_context marker not found')
|
|
||||||
|
|
||||||
routes = r'''
|
|
||||||
|
|
||||||
@app.get("/disk-images", response_class=HTMLResponse)
|
|
||||||
def disk_images_page(request: Request, error: str | None = None):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/disk-images/upload", response_class=HTMLResponse)
|
|
||||||
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
safe_name = safe_disk_image_filename(image_file.filename or "")
|
|
||||||
if not safe_name:
|
|
||||||
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем."}, status_code=400)
|
|
||||||
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
target = DISK_IMAGES_DIR / safe_name
|
|
||||||
if target.exists():
|
|
||||||
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Образ уже существует: {safe_name}"}, status_code=400)
|
|
||||||
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
|
|
||||||
try:
|
|
||||||
with tmp_target.open("wb") as out:
|
|
||||||
shutil.copyfileobj(image_file.file, out)
|
|
||||||
tmp_target.rename(target)
|
|
||||||
except Exception as exc:
|
|
||||||
tmp_target.unlink(missing_ok=True)
|
|
||||||
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки образа: {exc}"}, status_code=500)
|
|
||||||
return RedirectResponse(url="/disk-images", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/disk-images/{name}/delete")
|
|
||||||
def disk_image_delete(request: Request, name: str):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
path = disk_image_path_by_name(name)
|
|
||||||
if path and path.exists() and path.is_file():
|
|
||||||
path.unlink()
|
|
||||||
return RedirectResponse(url="/disk-images", status_code=303)
|
|
||||||
'''
|
|
||||||
|
|
||||||
if '@app.get("/disk-images"' not in text:
|
|
||||||
marker = '\n\n@app.get("/network", response_class=HTMLResponse)'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('network route marker not found')
|
|
||||||
text = text.replace(marker, routes + marker, 1)
|
|
||||||
changed.append('disk image routes added')
|
|
||||||
else:
|
|
||||||
changed.append('disk image routes already present')
|
|
||||||
|
|
||||||
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
|
|
||||||
new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
|
|
||||||
if old_sig in text:
|
|
||||||
text = text.replace(old_sig, new_sig, 1)
|
|
||||||
changed.append('vm create signature supports disk images and guest arch')
|
|
||||||
elif 'source_type: str = Form("iso")' in text and 'guest_arch: str = Form("auto")' not in text:
|
|
||||||
text = text.replace('source_type: str = Form("iso"), network_mode:', 'source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode:', 1)
|
|
||||||
changed.append('vm create signature upgraded with guest arch')
|
|
||||||
elif 'guest_arch: str = Form("auto")' in text:
|
|
||||||
changed.append('vm create signature already supports guest arch')
|
|
||||||
else:
|
|
||||||
warnings.append('vm_create_submit signature marker not found')
|
|
||||||
|
|
||||||
old_body = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}'
|
|
||||||
new_body = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
|
|
||||||
if old_body in text:
|
|
||||||
text = text.replace(old_body, new_body, 1)
|
|
||||||
changed.append('vm create form state supports source type and guest arch')
|
|
||||||
elif '"source_type": source_type' in text and '"guest_arch": guest_arch' not in text:
|
|
||||||
text = text.replace('"source_type": source_type, "network_mode"', '"source_type": source_type, "guest_arch": guest_arch, "network_mode"')
|
|
||||||
changed.append('vm create form state upgraded with guest arch')
|
|
||||||
|
|
||||||
old_iso_validation = ''' else:
|
|
||||||
iso = Path(iso_path).resolve()
|
|
||||||
if ISO_DIR.resolve() not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
|
|
||||||
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
|
|
||||||
'''
|
|
||||||
new_iso_validation = ''' elif source_type not in ("iso", "disk_image"):
|
|
||||||
error = "Некорректный источник VM."
|
|
||||||
elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):
|
|
||||||
error = "Некорректная архитектура VM."
|
|
||||||
elif network_mode == "bridge" and not bridge_exists(bridge):
|
|
||||||
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
|
|
||||||
else:
|
|
||||||
if source_type == "iso":
|
|
||||||
iso = Path(iso_path).resolve()
|
|
||||||
if ISO_DIR.resolve() not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
|
|
||||||
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
|
|
||||||
else:
|
|
||||||
disk_image = Path(disk_image_path).resolve()
|
|
||||||
if DISK_IMAGES_DIR.resolve() not in disk_image.parents or disk_image.suffix.lower() not in (".img", ".raw", ".qcow2") or not disk_image.exists():
|
|
||||||
error = "Образ диска должен быть существующим .img, .raw или .qcow2 файлом из /var/lib/virtuality/disk-images."
|
|
||||||
'''
|
|
||||||
if old_iso_validation in text:
|
|
||||||
text = text.replace(old_iso_validation, new_iso_validation, 1)
|
|
||||||
changed.append('vm create validation supports disk image source and guest arch')
|
|
||||||
elif 'guest_arch not in' not in text and 'source_type == "iso"' in text:
|
|
||||||
text = text.replace('elif network_mode not in ("nat", "bridge"):\n error = "Некорректный режим сети."', 'elif network_mode not in ("nat", "bridge"):\n error = "Некорректный режим сети."\n elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."', 1)
|
|
||||||
changed.append('vm create validation upgraded with guest arch')
|
|
||||||
elif 'guest_arch not in' in text:
|
|
||||||
changed.append('vm create validation already supports guest arch')
|
|
||||||
|
|
||||||
old_cmd = ''' IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
disk_path = IMAGES_DIR / f"{name}.qcow2"
|
|
||||||
if disk_path.exists():
|
|
||||||
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
|
|
||||||
|
|
||||||
profile = host_profile.load_host_profile()
|
|
||||||
is_arm = profile.get("recommended_guest_arch") == "aarch64"
|
|
||||||
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
|
|
||||||
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
|
|
||||||
if is_arm:
|
|
||||||
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
|
|
||||||
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
|
|
||||||
'''
|
|
||||||
new_cmd = ''' IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
disk_path = IMAGES_DIR / f"{name}.qcow2"
|
|
||||||
if disk_path.exists():
|
|
||||||
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
|
|
||||||
|
|
||||||
profile = host_profile.load_host_profile()
|
|
||||||
selected_arch = normalize_guest_arch(guest_arch, profile)
|
|
||||||
host_arch = str(profile.get("arch") or platform.machine() or "")
|
|
||||||
is_arm = selected_arch == "aarch64"
|
|
||||||
# ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation.
|
|
||||||
virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")
|
|
||||||
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
|
|
||||||
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--virt-type", virt_type]
|
|
||||||
if selected_arch == "x86_64":
|
|
||||||
cmd += ["--arch", "x86_64"]
|
|
||||||
elif is_arm:
|
|
||||||
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]
|
|
||||||
|
|
||||||
if source_type == "disk_image":
|
|
||||||
source_disk = Path(disk_image_path).resolve()
|
|
||||||
source_format = disk_image_format(source_disk)
|
|
||||||
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
|
|
||||||
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
|
|
||||||
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
|
|
||||||
else:
|
|
||||||
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
|
|
||||||
'''
|
|
||||||
if old_cmd in text:
|
|
||||||
text = text.replace(old_cmd, new_cmd, 1)
|
|
||||||
changed.append('vm create command supports ARM64 qemu fallback, guest arch and disk image import')
|
|
||||||
elif 'selected_arch = normalize_guest_arch' in text:
|
|
||||||
# Upgrade existing selected_arch block if it still uses kvm for aarch64 on x86.
|
|
||||||
text = re.sub(
|
|
||||||
r'virt_type = "kvm" if profile\.get\("kvm_device"\) else "qemu"',
|
|
||||||
'host_arch = str(profile.get("arch") or platform.machine() or "")\n # ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation.\n virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")',
|
|
||||||
text,
|
|
||||||
count=1,
|
|
||||||
)
|
|
||||||
changed.append('existing vm create command was upgraded with ARM64 qemu fallback')
|
|
||||||
elif 'virt_type = "kvm" if profile.get("kvm_device") else "qemu"' in text:
|
|
||||||
text = text.replace('is_arm = profile.get("recommended_guest_arch") == "aarch64"\n virt_type =', 'selected_arch = normalize_guest_arch(guest_arch, profile)\n host_arch = str(profile.get("arch") or platform.machine() or "")\n is_arm = selected_arch == "aarch64"\n virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else', 1)
|
|
||||||
text = text.replace('if is_arm:\n cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]', 'if selected_arch == "x86_64":\n cmd += ["--arch", "x86_64"]\n elif is_arm:\n cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]', 1)
|
|
||||||
changed.append('existing vm create command was upgraded with guest arch and ARM64 qemu fallback')
|
|
||||||
else:
|
|
||||||
warnings.append('vm command marker not found')
|
|
||||||
|
|
||||||
if '"guest_arch": selected_arch' not in text and '"guest_arch": profile.get("recommended_guest_arch")' in text:
|
|
||||||
text = text.replace('"guest_arch": profile.get("recommended_guest_arch")', '"guest_arch": selected_arch')
|
|
||||||
if '"disk_image_path": disk_image_path' not in text:
|
|
||||||
text = text.replace('"iso_path": iso_path, "host_profile"', '"iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": selected_arch, "host_profile"')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('disk images patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
for item in warnings:
|
|
||||||
print(f'WARN: {item}')
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
app_dir = app_path.resolve().parent
|
|
||||||
template_path = app_dir / 'templates' / 'vm_detail.html'
|
|
||||||
changed = []
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
|
|
||||||
if 'import tempfile' not in text:
|
|
||||||
text = text.replace('import subprocess\n', 'import subprocess\nimport tempfile\n', 1)
|
|
||||||
changed.append('tempfile import added')
|
|
||||||
else:
|
|
||||||
changed.append('tempfile import already present')
|
|
||||||
|
|
||||||
if 'import xml.etree.ElementTree as ET' not in text:
|
|
||||||
text = text.replace('from typing import Any\n', 'from typing import Any\nimport xml.etree.ElementTree as ET\n', 1)
|
|
||||||
changed.append('ElementTree import added')
|
|
||||||
else:
|
|
||||||
changed.append('ElementTree import already present')
|
|
||||||
|
|
||||||
helpers = r'''
|
|
||||||
|
|
||||||
def vm_boot_order_label(value: str) -> str:
|
|
||||||
labels = {item["value"]: item["label"] for item in vm_boot_order_options()}
|
|
||||||
return labels.get(value or "auto", "Auto — по источнику VM")
|
|
||||||
|
|
||||||
|
|
||||||
def boot_order_to_devs(value: str) -> list[str]:
|
|
||||||
value = normalize_boot_order(value, "disk_image")
|
|
||||||
mapping = {
|
|
||||||
"disk": ["hd"],
|
|
||||||
"cdrom_disk": ["cdrom", "hd"],
|
|
||||||
"disk_cdrom": ["hd", "cdrom"],
|
|
||||||
"network_disk": ["network", "hd"],
|
|
||||||
}
|
|
||||||
return mapping.get(value, ["hd"])
|
|
||||||
|
|
||||||
|
|
||||||
def boot_devs_to_order(devs: list[str]) -> str:
|
|
||||||
clean = [item for item in devs if item in ("hd", "cdrom", "network")]
|
|
||||||
if clean[:2] == ["cdrom", "hd"]:
|
|
||||||
return "cdrom_disk"
|
|
||||||
if clean[:2] == ["hd", "cdrom"]:
|
|
||||||
return "disk_cdrom"
|
|
||||||
if clean[:2] == ["network", "hd"]:
|
|
||||||
return "network_disk"
|
|
||||||
if clean[:1] == ["hd"]:
|
|
||||||
return "disk"
|
|
||||||
return "auto"
|
|
||||||
|
|
||||||
|
|
||||||
def current_vm_boot_order(name: str) -> str:
|
|
||||||
result = run_cmd(["virsh", "dumpxml", name], timeout=12)
|
|
||||||
if not result.get("ok"):
|
|
||||||
return "auto"
|
|
||||||
try:
|
|
||||||
root = ET.fromstring(result.get("stdout") or "")
|
|
||||||
except Exception:
|
|
||||||
return "auto"
|
|
||||||
os_node = root.find("os")
|
|
||||||
if os_node is None:
|
|
||||||
return "auto"
|
|
||||||
devs = []
|
|
||||||
for boot in os_node.findall("boot"):
|
|
||||||
dev = boot.attrib.get("dev", "").strip()
|
|
||||||
if dev:
|
|
||||||
devs.append(dev)
|
|
||||||
return boot_devs_to_order(devs)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_vm_boot_order(name: str, boot_order: str) -> tuple[bool, str]:
|
|
||||||
if not valid_vm_name(name) or not vm_exists(name):
|
|
||||||
return False, "VM не найдена."
|
|
||||||
if boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):
|
|
||||||
return False, "Некорректный порядок загрузки VM."
|
|
||||||
|
|
||||||
selected = normalize_boot_order(boot_order, "disk_image")
|
|
||||||
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
|
|
||||||
if not result.get("ok"):
|
|
||||||
return False, result.get("stderr") or "Не удалось получить XML VM."
|
|
||||||
|
|
||||||
try:
|
|
||||||
root = ET.fromstring(result.get("stdout") or "")
|
|
||||||
except Exception as exc:
|
|
||||||
return False, f"Не удалось разобрать XML VM: {exc}"
|
|
||||||
|
|
||||||
os_node = root.find("os")
|
|
||||||
if os_node is None:
|
|
||||||
os_node = ET.SubElement(root, "os")
|
|
||||||
|
|
||||||
for boot in list(os_node.findall("boot")):
|
|
||||||
os_node.remove(boot)
|
|
||||||
|
|
||||||
insert_at = 0
|
|
||||||
for idx, child in enumerate(list(os_node)):
|
|
||||||
if child.tag in ("type", "loader", "nvram", "firmware", "smbios", "bootmenu"):
|
|
||||||
insert_at = idx + 1
|
|
||||||
|
|
||||||
for dev in reversed(boot_order_to_devs(selected)):
|
|
||||||
boot_node = ET.Element("boot", {"dev": dev})
|
|
||||||
os_node.insert(insert_at, boot_node)
|
|
||||||
|
|
||||||
xml_text = ET.tostring(root, encoding="unicode")
|
|
||||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
|
|
||||||
handle.write(xml_text)
|
|
||||||
tmp_name = handle.name
|
|
||||||
try:
|
|
||||||
define = run_cmd(["virsh", "define", tmp_name], timeout=30)
|
|
||||||
finally:
|
|
||||||
Path(tmp_name).unlink(missing_ok=True)
|
|
||||||
if not define.get("ok"):
|
|
||||||
return False, define.get("stderr") or "virsh define завершился ошибкой."
|
|
||||||
return True, f"Порядок загрузки применён: {vm_boot_order_label(selected)}. Если VM запущена, изменение сработает после перезапуска."
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'def apply_vm_boot_order(name: str, boot_order: str)' not in text:
|
|
||||||
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, helpers + marker, 1)
|
|
||||||
changed.append('existing VM boot order helpers added')
|
|
||||||
else:
|
|
||||||
warnings.append('vm_details marker not found, helpers skipped')
|
|
||||||
else:
|
|
||||||
changed.append('existing VM boot order helpers already present')
|
|
||||||
|
|
||||||
old = '"host_ip": system_summary()["ip"]})'
|
|
||||||
new = '"host_ip": system_summary()["ip"], "boot_options": vm_boot_order_options(), "current_boot_order": current_vm_boot_order(name), "boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})'
|
|
||||||
if '"current_boot_order": current_vm_boot_order(name)' not in text:
|
|
||||||
if old in text:
|
|
||||||
text = text.replace(old, new, 1)
|
|
||||||
changed.append('vm detail context gets boot order')
|
|
||||||
else:
|
|
||||||
warnings.append('vm_detail context marker not found, skipped')
|
|
||||||
else:
|
|
||||||
changed.append('vm detail context already has boot order')
|
|
||||||
|
|
||||||
route = r'''
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/boot-order")
|
|
||||||
def vm_boot_order_apply(request: Request, name: str, boot_order: str = Form("auto")):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
ok, message = apply_vm_boot_order(name, boot_order)
|
|
||||||
if ok:
|
|
||||||
return RedirectResponse(url=f"/vm/{name}?boot_message={message}", status_code=303)
|
|
||||||
return RedirectResponse(url=f"/vm/{name}?boot_error={message}", status_code=303)
|
|
||||||
'''
|
|
||||||
|
|
||||||
if '@app.post("/vm/{name}/boot-order")' not in text:
|
|
||||||
marker = '\n\n@app.post("/vm/{name}/{action}")'
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, route + marker, 1)
|
|
||||||
changed.append('existing VM boot order route added')
|
|
||||||
else:
|
|
||||||
warnings.append('generic vm action marker not found, route skipped')
|
|
||||||
else:
|
|
||||||
changed.append('existing VM boot order route already present')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
|
|
||||||
if template_path.exists():
|
|
||||||
tpl = template_path.read_text()
|
|
||||||
original = tpl
|
|
||||||
if 'action="/vm/{{ vm.name }}/boot-order"' not in tpl:
|
|
||||||
block = r'''
|
|
||||||
|
|
||||||
{% if boot_message %}
|
|
||||||
<div class="alert success">{{ boot_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if boot_error %}
|
|
||||||
<div class="alert danger">{{ boot_error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h2>Порядок загрузки</h2>
|
|
||||||
<span class="pill">boot order</span>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid">
|
|
||||||
<label>
|
|
||||||
<span>Порядок загрузки VM</span>
|
|
||||||
<select name="boot_order" required>
|
|
||||||
{% for boot in boot_options %}
|
|
||||||
<option value="{{ boot.value }}" {% if current_boot_order == boot.value %}selected{% endif %}>{{ boot.label }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button class="primary wide" type="submit">Применить порядок загрузки</button>
|
|
||||||
</form>
|
|
||||||
<p class="muted small-note">Настройка меняет XML-конфигурацию VM через virsh define. Если машина сейчас запущена, новый порядок загрузки сработает после перезапуска.</p>
|
|
||||||
</section>
|
|
||||||
'''
|
|
||||||
markers = ['\n\n <section class="grid two">', '\n\n <section class="grid two">', '\n\n <section class="card">']
|
|
||||||
inserted = False
|
|
||||||
for marker in markers:
|
|
||||||
if marker in tpl:
|
|
||||||
tpl = tpl.replace(marker, block + marker, 1)
|
|
||||||
inserted = True
|
|
||||||
changed.append('boot order card added to vm_detail.html')
|
|
||||||
break
|
|
||||||
if not inserted:
|
|
||||||
warnings.append('vm detail insertion marker not found, boot order card skipped')
|
|
||||||
else:
|
|
||||||
changed.append('boot order card already present in vm_detail.html')
|
|
||||||
if tpl != original:
|
|
||||||
template_path.write_text(tpl)
|
|
||||||
else:
|
|
||||||
warnings.append(f'vm_detail.html not found: {template_path}')
|
|
||||||
|
|
||||||
print('existing VM boot order patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
if warnings:
|
|
||||||
print('Warnings:')
|
|
||||||
for item in warnings:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
app_dir = app_path.resolve().parent
|
|
||||||
template_path = app_dir / 'templates' / 'vm_detail.html'
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
|
|
||||||
helpers = r'''
|
|
||||||
|
|
||||||
def vm_state(name: str) -> str:
|
|
||||||
result = run_cmd(["virsh", "domstate", name], timeout=8)
|
|
||||||
return (result.get("stdout") or "unknown").strip().lower()
|
|
||||||
|
|
||||||
|
|
||||||
def current_vm_iso(name: str) -> str:
|
|
||||||
result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)
|
|
||||||
if not result.get("ok"):
|
|
||||||
return ""
|
|
||||||
for line in (result.get("stdout") or "").splitlines():
|
|
||||||
if ".iso" not in line.lower():
|
|
||||||
continue
|
|
||||||
parts = line.split()
|
|
||||||
if parts:
|
|
||||||
return parts[-1]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def detach_vm_iso(name: str) -> tuple[bool, str]:
|
|
||||||
if not valid_vm_name(name) or not vm_exists(name):
|
|
||||||
return False, "VM не найдена."
|
|
||||||
xml = run_cmd(["virsh", "dumpxml", name], timeout=12)
|
|
||||||
cdrom_targets: list[str] = []
|
|
||||||
if xml.get("ok"):
|
|
||||||
try:
|
|
||||||
root = ET.fromstring(xml.get("stdout") or "")
|
|
||||||
devices = root.find("devices")
|
|
||||||
if devices is not None:
|
|
||||||
for disk in devices.findall("disk"):
|
|
||||||
if disk.attrib.get("device") != "cdrom":
|
|
||||||
continue
|
|
||||||
target = disk.find("target")
|
|
||||||
dev = target.attrib.get("dev") if target is not None else ""
|
|
||||||
if dev:
|
|
||||||
cdrom_targets.append(dev)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not cdrom_targets:
|
|
||||||
cdrom_targets = ["sda", "hda", "sdb", "hdc"]
|
|
||||||
|
|
||||||
running = vm_state(name) == "running"
|
|
||||||
errors = []
|
|
||||||
changed_any = False
|
|
||||||
for target in cdrom_targets:
|
|
||||||
commands = []
|
|
||||||
if running:
|
|
||||||
commands.append(["virsh", "detach-disk", name, target, "--live"])
|
|
||||||
commands.append(["virsh", "detach-disk", name, target, "--config"])
|
|
||||||
for cmd in commands:
|
|
||||||
result = run_cmd(cmd, timeout=30)
|
|
||||||
if result.get("ok"):
|
|
||||||
changed_any = True
|
|
||||||
elif result.get("stderr"):
|
|
||||||
errors.append(result.get("stderr"))
|
|
||||||
if changed_any:
|
|
||||||
return True, "ISO был отмонтирован."
|
|
||||||
return False, errors[-1] if errors else "Подключенный ISO не найден."
|
|
||||||
|
|
||||||
|
|
||||||
def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]:
|
|
||||||
if not valid_vm_name(name) or not vm_exists(name):
|
|
||||||
return False, "VM не найдена."
|
|
||||||
iso = Path(iso_path or "").resolve()
|
|
||||||
try:
|
|
||||||
iso_root = ISO_DIR.resolve()
|
|
||||||
except Exception:
|
|
||||||
iso_root = Path("/var/lib/virtuality/iso")
|
|
||||||
if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
|
|
||||||
return False, "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
|
|
||||||
|
|
||||||
detach_vm_iso(name)
|
|
||||||
running = vm_state(name) == "running"
|
|
||||||
base = ["virsh", "attach-disk", name, str(iso), "sda", "--type", "cdrom", "--mode", "readonly"]
|
|
||||||
if running:
|
|
||||||
live = run_cmd(base + ["--live"], timeout=30)
|
|
||||||
if not live.get("ok"):
|
|
||||||
return False, live.get("stderr") or "Не удалось подключить ISO к запущенной VM."
|
|
||||||
config = run_cmd(base + ["--config"], timeout=30)
|
|
||||||
if not config.get("ok"):
|
|
||||||
return False, config.get("stderr") or "Не удалось сохранить ISO в конфигурации VM."
|
|
||||||
return True, "ISO был смонтирован в VM. Если гостевая ОС его не увидела сразу, перезагрузи VM или обнови устройства внутри гостевой ОС."
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'def mount_vm_iso(name: str, iso_path: str)' not in text:
|
|
||||||
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, helpers + marker, 1)
|
|
||||||
changed.append('existing VM ISO mount helpers added')
|
|
||||||
else:
|
|
||||||
changed.append('ISO helpers skipped: vm_details marker not found')
|
|
||||||
else:
|
|
||||||
changed.append('existing VM ISO mount helpers already present')
|
|
||||||
|
|
||||||
if '"current_iso": current_vm_iso(name)' not in text:
|
|
||||||
replacements = [
|
|
||||||
(
|
|
||||||
'"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})',
|
|
||||||
'"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})',
|
|
||||||
'"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'"host_ip": system_summary()["ip"]})',
|
|
||||||
'"host_ip": system_summary()["ip"], "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for old, new in replacements:
|
|
||||||
if old in text:
|
|
||||||
text = text.replace(old, new, 1)
|
|
||||||
changed.append('vm detail context gets ISO mount data')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
changed.append('ISO context skipped: vm detail context marker not found')
|
|
||||||
else:
|
|
||||||
changed.append('vm detail context already has ISO mount data')
|
|
||||||
|
|
||||||
routes = r'''
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/iso/mount")
|
|
||||||
def vm_iso_mount_apply(request: Request, name: str, iso_path: str = Form(...)):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
ok, message = mount_vm_iso(name, iso_path)
|
|
||||||
if ok:
|
|
||||||
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
|
|
||||||
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/iso/unmount")
|
|
||||||
def vm_iso_unmount_apply(request: Request, name: str):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
ok, message = detach_vm_iso(name)
|
|
||||||
if ok:
|
|
||||||
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
|
|
||||||
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
|
|
||||||
'''
|
|
||||||
|
|
||||||
if '@app.post("/vm/{name}/iso/mount")' not in text:
|
|
||||||
markers = ['\n\n@app.post("/vm/{name}/{action}")', '\n\n@app.get("/vm/create"', '\n\n@app.get("/operations"']
|
|
||||||
for marker in markers:
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, routes + marker, 1)
|
|
||||||
changed.append('existing VM ISO mount routes added')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
changed.append('ISO routes skipped: safe route marker not found')
|
|
||||||
else:
|
|
||||||
changed.append('existing VM ISO mount routes already present')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
|
|
||||||
iso_card = r'''
|
|
||||||
<article class="card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h2>ISO-привод</h2>
|
|
||||||
<span class="pill">cdrom</span>
|
|
||||||
</div>
|
|
||||||
{% if current_iso %}
|
|
||||||
<div class="notice">Сейчас подключен ISO: <b>{{ current_iso }}</b></div>
|
|
||||||
{% else %}
|
|
||||||
<div class="muted small-note">ISO сейчас не подключён.</div>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid">
|
|
||||||
<label>
|
|
||||||
<span>ISO образ</span>
|
|
||||||
<select name="iso_path" required>
|
|
||||||
{% for iso in isos %}
|
|
||||||
<option value="{{ iso.path }}" {% if current_iso == iso.path %}selected{% endif %}>{{ iso.name }} — {{ iso.size }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button class="primary wide" type="submit" {% if not isos %}disabled{% endif %}>Смонтировать ISO в VM</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');">
|
|
||||||
<button type="submit" class="ghost wide">Отмонтировать ISO</button>
|
|
||||||
</form>
|
|
||||||
{% if not isos %}
|
|
||||||
<div class="alert danger">ISO-образов пока нет. Сначала загрузи .iso в разделе ISO.</div>
|
|
||||||
{% endif %}
|
|
||||||
<p class="muted small-note">Для запущенной VM ISO подключается live и сохраняется в конфигурации. Для выключенной VM ISO будет доступен при следующем старте.</p>
|
|
||||||
</article>
|
|
||||||
'''
|
|
||||||
|
|
||||||
boot_card = r'''
|
|
||||||
<article class="card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h2>Порядок загрузки</h2>
|
|
||||||
<span class="pill">drag boot</span>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form">
|
|
||||||
<input type="hidden" name="boot_order" id="boot-order-value" value="{{ current_boot_order }}">
|
|
||||||
<div class="boot-order-list" id="boot-order-list" data-current="{{ current_boot_order }}">
|
|
||||||
<div class="boot-order-item" draggable="true" data-device="cdrom"><span class="drag-handle">☰</span><div><b>ISO / CD-ROM</b><small>Установщик или rescue-образ</small></div></div>
|
|
||||||
<div class="boot-order-item" draggable="true" data-device="hd"><span class="drag-handle">☰</span><div><b>Диск</b><small>Основной qcow2/raw диск VM</small></div></div>
|
|
||||||
<div class="boot-order-item" draggable="true" data-device="network"><span class="drag-handle">☰</span><div><b>Сеть / PXE</b><small>Загрузка по сети</small></div></div>
|
|
||||||
</div>
|
|
||||||
<button class="primary wide" type="submit">Применить порядок загрузки</button>
|
|
||||||
</form>
|
|
||||||
<p class="muted small-note">Перетащи нужный источник выше. Для загрузки с ISO поставь ISO / CD-ROM первым. Изменение сработает после перезапуска VM.</p>
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
const list = document.getElementById('boot-order-list');
|
|
||||||
const input = document.getElementById('boot-order-value');
|
|
||||||
if (!list || !input) return;
|
|
||||||
const initialMap = {'cdrom_disk':['cdrom','hd','network'],'disk_cdrom':['hd','cdrom','network'],'network_disk':['network','hd','cdrom'],'disk':['hd','cdrom','network'],'auto':['hd','cdrom','network']};
|
|
||||||
const order = initialMap[list.dataset.current || 'auto'] || initialMap.auto;
|
|
||||||
const nodes = Array.from(list.querySelectorAll('.boot-order-item'));
|
|
||||||
order.forEach(device => { const node = nodes.find(item => item.dataset.device === device); if (node) list.appendChild(node); });
|
|
||||||
function updateValue(){
|
|
||||||
const devices = Array.from(list.querySelectorAll('.boot-order-item')).map(item => item.dataset.device);
|
|
||||||
const first = devices[0]; const second = devices[1];
|
|
||||||
if (first === 'cdrom' && second === 'hd') input.value = 'cdrom_disk';
|
|
||||||
else if (first === 'hd' && second === 'cdrom') input.value = 'disk_cdrom';
|
|
||||||
else if (first === 'network' && second === 'hd') input.value = 'network_disk';
|
|
||||||
else if (first === 'hd') input.value = 'disk';
|
|
||||||
else input.value = 'auto';
|
|
||||||
}
|
|
||||||
let dragged = null;
|
|
||||||
list.addEventListener('dragstart', event => { dragged = event.target.closest('.boot-order-item'); if (!dragged) return; dragged.classList.add('dragging'); event.dataTransfer.effectAllowed = 'move'; });
|
|
||||||
list.addEventListener('dragend', () => { if (dragged) dragged.classList.remove('dragging'); dragged = null; updateValue(); });
|
|
||||||
list.addEventListener('dragover', event => { event.preventDefault(); const after = Array.from(list.querySelectorAll('.boot-order-item:not(.dragging)')).find(item => { const box = item.getBoundingClientRect(); return event.clientY < box.top + box.height / 2; }); if (!dragged) return; if (after) list.insertBefore(dragged, after); else list.appendChild(dragged); });
|
|
||||||
updateValue();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</article>
|
|
||||||
'''
|
|
||||||
|
|
||||||
if template_path.exists():
|
|
||||||
tpl = template_path.read_text()
|
|
||||||
original = tpl
|
|
||||||
settings_grid = '\n\n <section class="grid two vm-boot-iso-grid">\n' + iso_card + boot_card + ' </section>\n'
|
|
||||||
|
|
||||||
if 'action="/vm/{{ vm.name }}/iso/mount"' not in tpl and 'vm-boot-iso-grid' not in tpl:
|
|
||||||
markers = ['\n\n <section class="grid three vm-resource-boot-iso-grid">', '\n\n <section class="grid two">', '\n\n <section class="card">']
|
|
||||||
for marker in markers:
|
|
||||||
if marker in tpl:
|
|
||||||
if 'vm-resource-boot-iso-grid' in marker:
|
|
||||||
insert_pos = tpl.find(marker) + len(marker)
|
|
||||||
tpl = tpl[:insert_pos] + '\n' + iso_card + boot_card + tpl[insert_pos:]
|
|
||||||
changed.append('ISO and boot cards inserted into three-column grid')
|
|
||||||
else:
|
|
||||||
tpl = tpl.replace(marker, settings_grid + marker, 1)
|
|
||||||
changed.append('ISO and boot cards inserted by fallback')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
changed.append('ISO layout skipped: safe template marker not found')
|
|
||||||
elif 'vm-boot-iso-grid' in tpl and 'boot-order-list' not in tpl:
|
|
||||||
changed.append('ISO grid exists but draggable boot marker absent; left unchanged safely')
|
|
||||||
else:
|
|
||||||
changed.append('ISO mount template already present')
|
|
||||||
|
|
||||||
if tpl != original:
|
|
||||||
template_path.write_text(tpl)
|
|
||||||
else:
|
|
||||||
changed.append('ISO template skipped: vm_detail.html not found')
|
|
||||||
|
|
||||||
print('existing VM ISO mount patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
changed = []
|
|
||||||
warnings = []
|
|
||||||
text = app_path.read_text()
|
|
||||||
|
|
||||||
if 'import tempfile' not in text:
|
|
||||||
text = text.replace('import subprocess\n', 'import subprocess\nimport tempfile\n', 1)
|
|
||||||
changed.append('tempfile import added')
|
|
||||||
else:
|
|
||||||
changed.append('tempfile import already present')
|
|
||||||
|
|
||||||
if 'import xml.etree.ElementTree as ET' not in text:
|
|
||||||
text = text.replace('from typing import Any\n', 'from typing import Any\nimport xml.etree.ElementTree as ET\n', 1)
|
|
||||||
changed.append('ElementTree import added')
|
|
||||||
else:
|
|
||||||
changed.append('ElementTree import already present')
|
|
||||||
|
|
||||||
helpers = r'''
|
|
||||||
|
|
||||||
def parse_dominfo_value(dominfo: str, label: str) -> str:
|
|
||||||
for line in (dominfo or "").splitlines():
|
|
||||||
if line.strip().lower().startswith(label.lower()):
|
|
||||||
return line.split(":", 1)[1].strip()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def kib_text_to_mb(value: str) -> int:
|
|
||||||
match = re.search(r"(\d+)", value or "")
|
|
||||||
if not match:
|
|
||||||
return 0
|
|
||||||
return max(0, int(int(match.group(1)) / 1024))
|
|
||||||
|
|
||||||
|
|
||||||
def current_vm_arch(name: str) -> str:
|
|
||||||
result = run_cmd(["virsh", "dumpxml", name], timeout=12)
|
|
||||||
if not result.get("ok"):
|
|
||||||
return "unknown"
|
|
||||||
try:
|
|
||||||
root = ET.fromstring(result.get("stdout") or "")
|
|
||||||
except Exception:
|
|
||||||
return "unknown"
|
|
||||||
os_type = root.find("os/type")
|
|
||||||
return (os_type.attrib.get("arch") if os_type is not None else "") or "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def vm_runtime_state(name: str) -> str:
|
|
||||||
result = run_cmd(["virsh", "domstate", name], timeout=8)
|
|
||||||
return (result.get("stdout") or "unknown").strip().lower()
|
|
||||||
|
|
||||||
|
|
||||||
def vm_resource_settings(name: str) -> dict[str, Any]:
|
|
||||||
dominfo = run_cmd(["virsh", "dominfo", name], timeout=10).get("stdout") or ""
|
|
||||||
state = (parse_dominfo_value(dominfo, "State") or vm_runtime_state(name)).lower()
|
|
||||||
vcpus_raw = parse_dominfo_value(dominfo, "CPU(s)")
|
|
||||||
used_memory_raw = parse_dominfo_value(dominfo, "Used memory")
|
|
||||||
max_memory_raw = parse_dominfo_value(dominfo, "Max memory")
|
|
||||||
memory_mb = kib_text_to_mb(used_memory_raw) or kib_text_to_mb(max_memory_raw) or 1024
|
|
||||||
try:
|
|
||||||
vcpus = int(re.search(r"\d+", vcpus_raw or "1").group(0))
|
|
||||||
except Exception:
|
|
||||||
vcpus = 1
|
|
||||||
return {
|
|
||||||
"state": state,
|
|
||||||
"is_shutoff": state in ("shut off", "shutoff", "shut-off"),
|
|
||||||
"memory_mb": memory_mb,
|
|
||||||
"vcpus": vcpus,
|
|
||||||
"arch": current_vm_arch(name),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) -> tuple[bool, str]:
|
|
||||||
if not valid_vm_name(name) or not vm_exists(name):
|
|
||||||
return False, "VM не найдена."
|
|
||||||
resources = vm_resource_settings(name)
|
|
||||||
if not resources.get("is_shutoff"):
|
|
||||||
return False, "CPU/RAM/архитектуру можно менять только когда VM выключена. Сначала выключи VM."
|
|
||||||
if memory_mb < 512 or memory_mb > 262144:
|
|
||||||
return False, "RAM должна быть от 512 MB до 262144 MB."
|
|
||||||
if vcpus < 1 or vcpus > 128:
|
|
||||||
return False, "CPU должен быть от 1 до 128 vCPU."
|
|
||||||
if guest_arch not in ("keep", "x86_64", "aarch64"):
|
|
||||||
return False, "Некорректная архитектура VM."
|
|
||||||
|
|
||||||
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
|
|
||||||
if not result.get("ok"):
|
|
||||||
return False, result.get("stderr") or "Не удалось получить XML VM."
|
|
||||||
try:
|
|
||||||
root = ET.fromstring(result.get("stdout") or "")
|
|
||||||
except Exception as exc:
|
|
||||||
return False, f"Не удалось разобрать XML VM: {exc}"
|
|
||||||
|
|
||||||
memory_kib = str(int(memory_mb) * 1024)
|
|
||||||
for tag in ("memory", "currentMemory"):
|
|
||||||
node = root.find(tag)
|
|
||||||
if node is None:
|
|
||||||
node = ET.SubElement(root, tag)
|
|
||||||
node.text = memory_kib
|
|
||||||
node.set("unit", "KiB")
|
|
||||||
|
|
||||||
vcpu_node = root.find("vcpu")
|
|
||||||
if vcpu_node is None:
|
|
||||||
vcpu_node = ET.SubElement(root, "vcpu")
|
|
||||||
vcpu_node.text = str(int(vcpus))
|
|
||||||
vcpu_node.set("placement", "static")
|
|
||||||
|
|
||||||
arch_changed = False
|
|
||||||
if guest_arch != "keep":
|
|
||||||
os_type = root.find("os/type")
|
|
||||||
if os_type is None:
|
|
||||||
os_node = root.find("os")
|
|
||||||
if os_node is None:
|
|
||||||
os_node = ET.SubElement(root, "os")
|
|
||||||
os_type = ET.SubElement(os_node, "type")
|
|
||||||
os_type.text = "hvm"
|
|
||||||
old_arch = os_type.attrib.get("arch", "")
|
|
||||||
if old_arch != guest_arch:
|
|
||||||
os_type.set("arch", guest_arch)
|
|
||||||
if guest_arch == "aarch64":
|
|
||||||
os_type.set("machine", "virt")
|
|
||||||
arch_changed = True
|
|
||||||
|
|
||||||
xml_text = ET.tostring(root, encoding="unicode")
|
|
||||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
|
|
||||||
handle.write(xml_text)
|
|
||||||
tmp_name = handle.name
|
|
||||||
try:
|
|
||||||
define = run_cmd(["virsh", "define", tmp_name], timeout=30)
|
|
||||||
finally:
|
|
||||||
Path(tmp_name).unlink(missing_ok=True)
|
|
||||||
if not define.get("ok"):
|
|
||||||
return False, define.get("stderr") or "virsh define завершился ошибкой."
|
|
||||||
|
|
||||||
message = f"Ресурсы VM применены: CPU {vcpus}, RAM {memory_mb} MB"
|
|
||||||
if arch_changed:
|
|
||||||
message += f", архитектура {guest_arch}. Важно: смена архитектуры может потребовать совместимый диск/загрузчик."
|
|
||||||
return True, message
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str)' not in text:
|
|
||||||
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, helpers + marker, 1)
|
|
||||||
changed.append('existing VM resources helpers added')
|
|
||||||
else:
|
|
||||||
warnings.append('vm_details marker not found, helpers skipped')
|
|
||||||
else:
|
|
||||||
changed.append('existing VM resources helpers already present')
|
|
||||||
|
|
||||||
if '"resource_settings": vm_resource_settings(name)' not in text:
|
|
||||||
replacements = [
|
|
||||||
('"iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})', '"iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
|
|
||||||
('"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})', '"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
|
|
||||||
('"host_ip": system_summary()["ip"]})', '"host_ip": system_summary()["ip"], "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
|
|
||||||
]
|
|
||||||
for old, new in replacements:
|
|
||||||
if old in text:
|
|
||||||
text = text.replace(old, new, 1)
|
|
||||||
changed.append('vm detail context gets resource settings')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
warnings.append('vm detail context marker not found, resource context skipped')
|
|
||||||
else:
|
|
||||||
changed.append('vm detail context already has resource settings')
|
|
||||||
|
|
||||||
route = r'''
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/resources")
|
|
||||||
def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), vcpus: int = Form(...), guest_arch: str = Form("keep")):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
ok, message = apply_vm_resources(name, memory_mb, vcpus, guest_arch)
|
|
||||||
if ok:
|
|
||||||
return RedirectResponse(url=f"/vm/{name}?resource_message={message}", status_code=303)
|
|
||||||
return RedirectResponse(url=f"/vm/{name}?resource_error={message}", status_code=303)
|
|
||||||
'''
|
|
||||||
|
|
||||||
if '@app.post("/vm/{name}/resources")' not in text:
|
|
||||||
markers = ['\n\n@app.post("/vm/{name}/boot-order")', '\n\n@app.post("/vm/{name}/iso/mount")', '\n\n@app.post("/vm/{name}/{action}")']
|
|
||||||
for marker in markers:
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, route + marker, 1)
|
|
||||||
changed.append('existing VM resources route added')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
warnings.append('vm action marker not found, resources route skipped')
|
|
||||||
else:
|
|
||||||
changed.append('existing VM resources route already present')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
|
|
||||||
print('existing VM resources patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
if warnings:
|
|
||||||
print('Warnings:')
|
|
||||||
for item in warnings:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
app_dir = app_path.parent
|
|
||||||
static_dir = app_dir / 'static'
|
|
||||||
static_dir.mkdir(exist_ok=True)
|
|
||||||
panel_js_path = static_dir / 'panel.js'
|
|
||||||
app_css_path = static_dir / 'app.css'
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
live_routes = r'''
|
|
||||||
|
|
||||||
@app.get("/live/status")
|
|
||||||
def live_status(request: Request):
|
|
||||||
if not get_current_user(request):
|
|
||||||
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
|
||||||
vms = []
|
|
||||||
for vm in parse_virsh_list():
|
|
||||||
name = vm.get("name", "")
|
|
||||||
state = vm.get("state", "unknown")
|
|
||||||
try:
|
|
||||||
ip = vm_ip(name) if name else "—"
|
|
||||||
except Exception:
|
|
||||||
ip = "—"
|
|
||||||
css = "ok" if "running" in state else "err" if "shut" in state else "warn"
|
|
||||||
vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "—"})
|
|
||||||
return JSONResponse({"ok": True, "generated_at": utc_now(), "vms": vms, "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "web": service_state("virtuality-web.service")}, "operations": list_operations(5)})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/live/operations")
|
|
||||||
def live_operations(request: Request):
|
|
||||||
if not get_current_user(request):
|
|
||||||
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
|
||||||
return JSONResponse({"ok": True, "generated_at": utc_now(), "operations": list_operations(25)})
|
|
||||||
'''
|
|
||||||
if '@app.get("/live/status"' not in text:
|
|
||||||
marker = '\n\n@app.get("/api/health")'
|
|
||||||
text = text.replace(marker, live_routes + marker, 1) if marker in text else text.rstrip() + live_routes + '\n'
|
|
||||||
changed.append('live routes added')
|
|
||||||
else:
|
|
||||||
changed.append('live routes already present')
|
|
||||||
app_path.write_text(text)
|
|
||||||
|
|
||||||
panel_js = panel_js_path.read_text() if panel_js_path.exists() else ''
|
|
||||||
live_js = r'''
|
|
||||||
|
|
||||||
/* Virtuality live status + toast layer */
|
|
||||||
(() => {
|
|
||||||
if (window.VirtualityLiveStatus) return;
|
|
||||||
window.VirtualityLiveStatus = true;
|
|
||||||
function qs(s, r = document) { return r.querySelector(s); }
|
|
||||||
function qsa(s, r = document) { return Array.from(r.querySelectorAll(s)); }
|
|
||||||
function toast(message, type = 'ok') {
|
|
||||||
if (!message) return;
|
|
||||||
let wrap = qs('#v-toast-wrap');
|
|
||||||
if (!wrap) { wrap = document.createElement('div'); wrap.id = 'v-toast-wrap'; document.body.appendChild(wrap); }
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'v-toast ' + type;
|
|
||||||
item.textContent = message;
|
|
||||||
wrap.appendChild(item);
|
|
||||||
setTimeout(() => item.classList.add('show'), 20);
|
|
||||||
setTimeout(() => { item.classList.remove('show'); setTimeout(() => item.remove(), 260); }, 3600);
|
|
||||||
}
|
|
||||||
function vmNameFromRow(row) {
|
|
||||||
const link = qs('a[href^="/vm/"]', row);
|
|
||||||
if (!link) return '';
|
|
||||||
try { const p = new URL(link.href, location.href).pathname.split('/').filter(Boolean); return p[0] === 'vm' ? decodeURIComponent(p[1] || '') : ''; } catch (_) { return ''; }
|
|
||||||
}
|
|
||||||
function stateClass(state) { const v = String(state || '').toLowerCase(); return v.includes('running') ? 'ok' : v.includes('shut') ? 'err' : 'warn'; }
|
|
||||||
function updateDashboardRows(vms) {
|
|
||||||
if (!Array.isArray(vms)) return;
|
|
||||||
const map = new Map(vms.map((vm) => [vm.name, vm]));
|
|
||||||
qsa('table tbody tr').forEach((row) => {
|
|
||||||
const name = vmNameFromRow(row);
|
|
||||||
if (!name || !map.has(name)) return;
|
|
||||||
const vm = map.get(name);
|
|
||||||
const cells = qsa('td', row);
|
|
||||||
if (cells[0]) cells[0].textContent = vm.id || '-';
|
|
||||||
if (cells[2]) cells[2].textContent = vm.ip || '—';
|
|
||||||
if (cells[3]) {
|
|
||||||
let badge = qs('.status', cells[3]);
|
|
||||||
if (!badge) { badge = document.createElement('span'); cells[3].textContent = ''; cells[3].appendChild(badge); }
|
|
||||||
badge.className = 'status ' + (vm.state_css || stateClass(vm.state));
|
|
||||||
badge.textContent = vm.state || 'unknown';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function updateDetailHeader(vms) {
|
|
||||||
const title = qs('.brand');
|
|
||||||
if (!title || !Array.isArray(vms)) return;
|
|
||||||
const vm = vms.find((item) => item.name === title.textContent.trim());
|
|
||||||
if (!vm) return;
|
|
||||||
let badge = qs('#vm-live-badge');
|
|
||||||
if (!badge) { badge = document.createElement('span'); badge.id = 'vm-live-badge'; title.insertAdjacentElement('afterend', badge); }
|
|
||||||
badge.className = 'status live-badge ' + (vm.state_css || stateClass(vm.state));
|
|
||||||
badge.textContent = vm.state || 'unknown';
|
|
||||||
}
|
|
||||||
async function refreshLiveStatus() {
|
|
||||||
if (document.hidden || (!qs('.v-main') && !qs('.shell'))) return;
|
|
||||||
try {
|
|
||||||
const r = await fetch('/live/status', { cache: 'no-store', headers: { 'Accept': 'application/json' }});
|
|
||||||
if (!r.ok) return;
|
|
||||||
const p = await r.json();
|
|
||||||
if (!p.ok) return;
|
|
||||||
updateDashboardRows(p.vms);
|
|
||||||
updateDetailHeader(p.vms);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
document.addEventListener('submit', (event) => {
|
|
||||||
const form = event.target;
|
|
||||||
if (!form || !form.action) return;
|
|
||||||
const parts = new URL(form.action, location.href).pathname.split('/').filter(Boolean);
|
|
||||||
if (parts[0] !== 'vm' || parts.length < 3) return;
|
|
||||||
const labels = { start: 'Команда запуска VM отправлена', shutdown: 'Команда мягкого выключения VM отправлена', reboot: 'Команда перезагрузки VM отправлена', destroy: 'Команда принудительного выключения VM отправлена', autostart: 'Автозапуск VM включается', 'autostart-disable': 'Автозапуск VM отключается', delete: 'Удаление VM запущено' };
|
|
||||||
const action = parts[2];
|
|
||||||
if (labels[action]) { const type = action === 'destroy' || action === 'delete' ? 'warn' : 'ok'; sessionStorage.setItem('virtualityToast', labels[action]); toast(labels[action], type); setTimeout(refreshLiveStatus, 900); setTimeout(refreshLiveStatus, 2500); }
|
|
||||||
}, true);
|
|
||||||
function boot() { const m = sessionStorage.getItem('virtualityToast'); if (m) { sessionStorage.removeItem('virtualityToast'); toast(m, 'ok'); } refreshLiveStatus(); setInterval(refreshLiveStatus, 5000); }
|
|
||||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
|
|
||||||
})();
|
|
||||||
'''
|
|
||||||
if 'Virtuality live status + toast layer' not in panel_js:
|
|
||||||
panel_js_path.write_text(panel_js.rstrip() + live_js + '\n')
|
|
||||||
changed.append('live javascript added')
|
|
||||||
else:
|
|
||||||
changed.append('live javascript already present')
|
|
||||||
|
|
||||||
app_css = app_css_path.read_text() if app_css_path.exists() else ''
|
|
||||||
live_css = r'''
|
|
||||||
|
|
||||||
/* Virtuality live status + toast layer */
|
|
||||||
#v-toast-wrap { position: fixed; top: 14px; right: 14px; z-index: 10000; display: grid; gap: 8px; width: min(360px, calc(100vw - 28px)); pointer-events: none; }
|
|
||||||
.v-toast { transform: translateY(-8px); opacity: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: #fff; color: var(--text); box-shadow: 0 12px 32px rgba(15,23,42,.18); font-size: 13px; font-weight: 800; transition: .22s ease; }
|
|
||||||
.v-toast.show { transform: translateY(0); opacity: 1; }
|
|
||||||
.v-toast.ok { border-color: rgba(22,163,74,.28); box-shadow: 0 12px 32px rgba(22,163,74,.16); }
|
|
||||||
.v-toast.warn { border-color: rgba(217,119,6,.32); box-shadow: 0 12px 32px rgba(217,119,6,.16); }
|
|
||||||
.v-toast.err { border-color: rgba(220,38,38,.32); box-shadow: 0 12px 32px rgba(220,38,38,.16); }
|
|
||||||
.live-badge { margin-left: 10px; vertical-align: middle; }
|
|
||||||
'''
|
|
||||||
if 'Virtuality live status + toast layer' not in app_css:
|
|
||||||
app_css_path.write_text(app_css.rstrip() + live_css + '\n')
|
|
||||||
changed.append('live css added')
|
|
||||||
else:
|
|
||||||
changed.append('live css already present')
|
|
||||||
|
|
||||||
print('live status patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
templates_dir = app_path.parent / 'templates'
|
|
||||||
static_dir = app_path.parent / 'static'
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
helpers = r'''
|
|
||||||
|
|
||||||
LOG_SOURCES = {
|
|
||||||
"web": {"title": "Virtuality Web", "kind": "journal", "unit": "virtuality-web.service"},
|
|
||||||
"update": {"title": "Update Center", "kind": "file", "path": "/var/log/virtuality/update.log"},
|
|
||||||
"install": {"title": "Последняя установка", "kind": "glob", "pattern": "/var/log/virtuality/install_web_panel_*.log"},
|
|
||||||
"operations": {"title": "Операции", "kind": "operations"},
|
|
||||||
"auto-update": {"title": "Auto Update", "kind": "journal", "unit": "virtuality-auto-update.service"},
|
|
||||||
"libvirtd": {"title": "libvirtd", "kind": "journal", "unit": "libvirtd.service"},
|
|
||||||
"virtlogd": {"title": "virtlogd", "kind": "journal", "unit": "virtlogd.service"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def read_log_source(source: str, lines: int = 220) -> dict[str, Any]:
|
|
||||||
key = source if source in LOG_SOURCES else "web"
|
|
||||||
cfg = LOG_SOURCES[key]
|
|
||||||
lines = max(20, min(int(lines or 220), 2000))
|
|
||||||
content = ""
|
|
||||||
path = ""
|
|
||||||
cmd = ""
|
|
||||||
if cfg["kind"] == "journal":
|
|
||||||
unit = cfg["unit"]
|
|
||||||
cmd = f"journalctl -u {unit} -n {lines} --no-pager"
|
|
||||||
result = run_cmd(["journalctl", "-u", unit, "-n", str(lines), "--no-pager"], timeout=15)
|
|
||||||
content = result["stdout"] or result["stderr"] or "Лог пуст или journalctl недоступен"
|
|
||||||
elif cfg["kind"] == "file":
|
|
||||||
path = cfg["path"]
|
|
||||||
cmd = f"tail -n {lines} {path}"
|
|
||||||
content = tail_text(Path(path), max_lines=lines) or "Файл лога пока пуст или не найден"
|
|
||||||
elif cfg["kind"] == "glob":
|
|
||||||
pattern = cfg["pattern"]
|
|
||||||
files = sorted(Path('/').glob(pattern.lstrip('/')), key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True)
|
|
||||||
if files:
|
|
||||||
path = str(files[0])
|
|
||||||
cmd = f"tail -n {lines} {path}"
|
|
||||||
content = tail_text(files[0], max_lines=lines) or "Файл лога пуст"
|
|
||||||
else:
|
|
||||||
cmd = f"ls {pattern}"
|
|
||||||
content = "Логи установки ещё не найдены"
|
|
||||||
elif cfg["kind"] == "operations":
|
|
||||||
cmd = f"tail -n {lines} /var/log/virtuality/operations/*.log"
|
|
||||||
parts = []
|
|
||||||
ensure_operations_dir()
|
|
||||||
for item in sorted(OPERATIONS_DIR.glob('*.log'), key=lambda p: p.stat().st_mtime, reverse=True)[:10]:
|
|
||||||
parts.append(f"===== {item.name} =====\n" + tail_text(item, max_lines=max(20, lines // 5)))
|
|
||||||
content = "\n\n".join(parts) or "Журналы операций пока пусты"
|
|
||||||
return {"key": key, "title": cfg["title"], "content": content, "path": path, "cmd": cmd, "lines": lines}
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'LOG_SOURCES = {' not in text:
|
|
||||||
markers = [
|
|
||||||
'\n\ndef parse_virsh_list() -> list[dict[str, str]]:',
|
|
||||||
'\n\ndef list_recent_operations(limit: int = 20) -> list[dict[str, Any]]:',
|
|
||||||
'\n\ndef safe_iso_filename(filename: str) -> str | None:',
|
|
||||||
]
|
|
||||||
for marker in markers:
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, helpers + marker, 1)
|
|
||||||
changed.append('log center helpers added')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
print('WARN: log helper marker not found, skip helper injection')
|
|
||||||
changed.append('log center helpers skipped')
|
|
||||||
else:
|
|
||||||
start = text.find('LOG_SOURCES = {')
|
|
||||||
end = text.find('\n}\n\n\ndef read_log_source', start)
|
|
||||||
if start != -1 and end != -1:
|
|
||||||
replacement = helpers.strip().split('\n\n\ndef read_log_source', 1)[0]
|
|
||||||
text = text[:start] + replacement + text[end + 3:]
|
|
||||||
changed.append('log sources refreshed without telegram')
|
|
||||||
else:
|
|
||||||
text = text.replace(' "telegram": {"title": "Telegram notifier", "kind": "file", "path": "/var/log/virtuality/telegram_version_bot.log"},\n', '')
|
|
||||||
changed.append('telegram log source removed')
|
|
||||||
|
|
||||||
route = r'''
|
|
||||||
|
|
||||||
@app.get("/logs", response_class=HTMLResponse)
|
|
||||||
def logs_page(request: Request, source: str = "web", lines: int = 220):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
selected = read_log_source(source, lines)
|
|
||||||
return templates.TemplateResponse("logs.html", {
|
|
||||||
"request": request,
|
|
||||||
"app_name": APP_NAME,
|
|
||||||
"user": AUTH_USER,
|
|
||||||
"sources": LOG_SOURCES,
|
|
||||||
"selected": selected,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/logs")
|
|
||||||
def api_logs(request: Request, source: str = "web", lines: int = 220):
|
|
||||||
if not get_current_user(request):
|
|
||||||
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
|
||||||
return {"ok": True, "log": read_log_source(source, lines)}
|
|
||||||
'''
|
|
||||||
|
|
||||||
if '@app.get("/logs"' not in text:
|
|
||||||
markers = ['\n\n@app.get("/vm/create", response_class=HTMLResponse)', '\n\n@app.get("/api/operations")', '\n\n@app.get("/iso", response_class=HTMLResponse)']
|
|
||||||
for marker in markers:
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, route + marker, 1)
|
|
||||||
changed.append('logs routes added')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
print('WARN: logs route insert marker not found, skip route injection')
|
|
||||||
changed.append('logs routes skipped')
|
|
||||||
else:
|
|
||||||
changed.append('logs routes already present')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
|
|
||||||
sidebar_html = '''{% set path = request.url.path %}
|
|
||||||
<aside class="v-sidebar">
|
|
||||||
<div class="v-logo">
|
|
||||||
<strong>Virtuality</strong>
|
|
||||||
<span>control panel</span>
|
|
||||||
</div>
|
|
||||||
<nav class="v-nav">
|
|
||||||
<a class="{{ 'active' if path == '/' else '' }}" href="/">Обзор</a>
|
|
||||||
<a class="{{ 'active' if path.startswith('/vm/create') else '' }}" href="/vm/create">Создать VM</a>
|
|
||||||
<div class="v-nav-group {{ 'open' if path.startswith('/iso') or path.startswith('/disk-images') else '' }}">
|
|
||||||
<button class="v-nav-group-title" type="button">Образы</button>
|
|
||||||
<a class="{{ 'active' if path.startswith('/iso') else '' }}" href="/iso">ISO</a>
|
|
||||||
<a class="{{ 'active' if path.startswith('/disk-images') else '' }}" href="/disk-images">Диски</a>
|
|
||||||
</div>
|
|
||||||
<a class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
|
|
||||||
<a class="{{ 'active' if path.startswith('/operations') else '' }}" href="/operations">Операции</a>
|
|
||||||
<a class="{{ 'active' if path.startswith('/logs') else '' }}" href="/logs">Журналы</a>
|
|
||||||
<a class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
|
|
||||||
<a class="{{ 'active' if path.startswith('/host') else '' }}" href="/host">Хост</a>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
'''
|
|
||||||
|
|
||||||
if templates_dir.exists():
|
|
||||||
sidebar_path = templates_dir / '_sidebar.html'
|
|
||||||
sidebar_path.write_text(sidebar_html)
|
|
||||||
changed.append('_sidebar.html ensured')
|
|
||||||
|
|
||||||
for path in sorted(templates_dir.glob('*.html')):
|
|
||||||
if path.name in {'login.html', 'console.html', '_sidebar.html'}:
|
|
||||||
continue
|
|
||||||
html = path.read_text()
|
|
||||||
if '<script src="/static/panel.js" defer></script>' not in html:
|
|
||||||
html = html.replace('</body>', ' <script src="/static/panel.js" defer></script>\n</body>', 1)
|
|
||||||
changed.append(f'{path.name} panel.js attached')
|
|
||||||
path.write_text(html)
|
|
||||||
|
|
||||||
print('logs center and UI dynamics patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
core_path = app_path.with_name('network_core.py')
|
|
||||||
if not core_path.exists():
|
|
||||||
raise SystemExit(f'network_core.py not found: {core_path}')
|
|
||||||
|
|
||||||
text = core_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
if 'def route_interface_for_ip(' not in text:
|
|
||||||
marker = '''def external_interface() -> str:
|
|
||||||
result = run_cmd(['ip', 'route', 'show', 'default'], timeout=5)
|
|
||||||
if not result['ok']:
|
|
||||||
return 'eth0'
|
|
||||||
match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout'])
|
|
||||||
return match.group(1) if match else 'eth0'
|
|
||||||
'''
|
|
||||||
helper = marker + '''
|
|
||||||
|
|
||||||
def route_interface_for_ip(ip: str) -> str:
|
|
||||||
if not valid_ip(ip):
|
|
||||||
return NAT_BRIDGE
|
|
||||||
result = run_cmd(['ip', 'route', 'get', ip], timeout=5)
|
|
||||||
if not result['ok']:
|
|
||||||
return NAT_BRIDGE
|
|
||||||
match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout'])
|
|
||||||
return match.group(1) if match else NAT_BRIDGE
|
|
||||||
|
|
||||||
|
|
||||||
def forward_guest_interface(item: dict[str, Any]) -> str:
|
|
||||||
return route_interface_for_ip(str(item.get('guest_ip', '')))
|
|
||||||
'''
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('external_interface marker not found')
|
|
||||||
text = text.replace(marker, helper, 1)
|
|
||||||
changed.append('route_interface_for_ip helper added')
|
|
||||||
else:
|
|
||||||
changed.append('route_interface_for_ip helper already present')
|
|
||||||
|
|
||||||
old_render = '''def render_nft_rules() -> str:
|
|
||||||
ext = external_interface()
|
|
||||||
lines = [
|
|
||||||
'table ip virtuality {',
|
|
||||||
' chain prerouting {',
|
|
||||||
' type nat hook prerouting priority dstnat; policy accept;',
|
|
||||||
]
|
|
||||||
for item in load_port_forwards():
|
|
||||||
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
|
|
||||||
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
|
|
||||||
lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
|
|
||||||
lines += [
|
|
||||||
' }',
|
|
||||||
' chain postrouting {',
|
|
||||||
' type nat hook postrouting priority srcnat; policy accept;',
|
|
||||||
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
|
|
||||||
' }',
|
|
||||||
' chain forward {',
|
|
||||||
' type filter hook forward priority filter; policy accept;',
|
|
||||||
f' ip saddr {NAT_SUBNET} accept',
|
|
||||||
f' ip daddr {NAT_SUBNET} accept',
|
|
||||||
' }',
|
|
||||||
'}',
|
|
||||||
]
|
|
||||||
return '\\n'.join(lines) + '\\n'
|
|
||||||
'''
|
|
||||||
new_render = '''def render_nft_rules() -> str:
|
|
||||||
ext = external_interface()
|
|
||||||
forwards = load_port_forwards()
|
|
||||||
lines = [
|
|
||||||
'table ip virtuality {',
|
|
||||||
' chain prerouting {',
|
|
||||||
' type nat hook prerouting priority dstnat; policy accept;',
|
|
||||||
]
|
|
||||||
for item in forwards:
|
|
||||||
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
|
|
||||||
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
|
|
||||||
lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
|
|
||||||
lines += [
|
|
||||||
' }',
|
|
||||||
' chain postrouting {',
|
|
||||||
' type nat hook postrouting priority srcnat; policy accept;',
|
|
||||||
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
|
|
||||||
]
|
|
||||||
for item in forwards:
|
|
||||||
guest_iface = forward_guest_interface(item)
|
|
||||||
lines.append(f' ip daddr {item["guest_ip"]} oifname "{guest_iface}" masquerade')
|
|
||||||
lines += [
|
|
||||||
' }',
|
|
||||||
' chain forward {',
|
|
||||||
' type filter hook forward priority filter; policy accept;',
|
|
||||||
f' ip saddr {NAT_SUBNET} accept',
|
|
||||||
f' ip daddr {NAT_SUBNET} accept',
|
|
||||||
]
|
|
||||||
for item in forwards:
|
|
||||||
guest_iface = forward_guest_interface(item)
|
|
||||||
lines.append(f' iifname "{ext}" oifname "{guest_iface}" ip daddr {item["guest_ip"]} accept')
|
|
||||||
lines.append(f' iifname "{guest_iface}" oifname "{ext}" ip saddr {item["guest_ip"]} ct state established,related accept')
|
|
||||||
lines += [
|
|
||||||
' }',
|
|
||||||
'}',
|
|
||||||
]
|
|
||||||
return '\\n'.join(lines) + '\\n'
|
|
||||||
'''
|
|
||||||
if old_render in text:
|
|
||||||
text = text.replace(old_render, new_render, 1)
|
|
||||||
changed.append('nft rules now support bridge/static VM interfaces')
|
|
||||||
elif 'forward_guest_interface(item)' in text and 'ip daddr {item["guest_ip"]}' in text:
|
|
||||||
changed.append('nft bridge/static rules already present')
|
|
||||||
else:
|
|
||||||
raise SystemExit('render_nft_rules marker not found')
|
|
||||||
|
|
||||||
text = text.replace(
|
|
||||||
"'out', 'on', NAT_BRIDGE,\n 'to', item['guest_ip'],",
|
|
||||||
"'out', 'on', forward_guest_interface(item),\n 'to', item['guest_ip'],",
|
|
||||||
)
|
|
||||||
if "'out', 'on', forward_guest_interface(item)" in text:
|
|
||||||
changed.append('UFW route rules now use guest route interface')
|
|
||||||
|
|
||||||
old_iptables_line = """ results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', NAT_BRIDGE, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
|
|
||||||
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', NAT_BRIDGE, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
|
|
||||||
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
|
|
||||||
"""
|
|
||||||
new_iptables_line = """ guest_iface = forward_guest_interface(item)
|
|
||||||
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
|
|
||||||
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
|
|
||||||
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
|
|
||||||
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, '-j', 'MASQUERADE']))
|
|
||||||
"""
|
|
||||||
if old_iptables_line in text:
|
|
||||||
text = text.replace(old_iptables_line, new_iptables_line, 1)
|
|
||||||
changed.append('iptables fallback now supports bridge/static VM interfaces')
|
|
||||||
elif "guest_iface = forward_guest_interface(item)" in text and "'-o', guest_iface" in text:
|
|
||||||
changed.append('iptables bridge/static fallback already present')
|
|
||||||
else:
|
|
||||||
raise SystemExit('iptables fallback marker not found')
|
|
||||||
|
|
||||||
text = text.replace(
|
|
||||||
"'watch_internal': f\"sudo tcpdump -ni {NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'\",",
|
|
||||||
"'watch_internal': f\"sudo tcpdump -ni {route_interface_for_ip(vm_ip) if vm_ip else NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'\",",
|
|
||||||
)
|
|
||||||
|
|
||||||
core_path.write_text(text)
|
|
||||||
print('network bridge forward patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
if 'def network_diagnose(' in text:
|
|
||||||
print('network diagnostics handler already applied')
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
handler = '''
|
|
||||||
|
|
||||||
@app.post("/network/diagnose", response_class=HTMLResponse)
|
|
||||||
def network_diagnose(request: Request, vm_name: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp")):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
diagnostics = None
|
|
||||||
error = None
|
|
||||||
try:
|
|
||||||
diagnostics = network_core.diagnose_public_access(vm_name, external_port, guest_port, protocol)
|
|
||||||
except NetworkError as exc:
|
|
||||||
error = str(exc)
|
|
||||||
return templates.TemplateResponse("network.html", {
|
|
||||||
"request": request,
|
|
||||||
"app_name": APP_NAME,
|
|
||||||
"user": AUTH_USER,
|
|
||||||
"vms": parse_virsh_list(),
|
|
||||||
"ctx": network_core.network_context(),
|
|
||||||
"error": error,
|
|
||||||
"diagnostics": diagnostics,
|
|
||||||
}, status_code=400 if error else 200)
|
|
||||||
'''
|
|
||||||
|
|
||||||
marker = '\n\n@app.get("/operations", response_class=HTMLResponse)'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('operations marker not found in app.py')
|
|
||||||
text = text.replace(marker, handler + marker, 1)
|
|
||||||
app_path.write_text(text)
|
|
||||||
print(f'network diagnostics handler applied: {app_path}')
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
template_path = app_path.with_name('templates') / 'network.html'
|
|
||||||
if not template_path.exists():
|
|
||||||
raise SystemExit(f'network.html not found: {template_path}')
|
|
||||||
|
|
||||||
text = template_path.read_text()
|
|
||||||
old = '<input type="hidden" name="guest_ip" value="auto">'
|
|
||||||
new = '''<label>
|
|
||||||
<span>IP VM</span>
|
|
||||||
<input type="text" name="guest_ip" value="auto" placeholder="auto или 192.168.100.55" required>
|
|
||||||
<small>Если IP не определяется автоматически, впиши внутренний IP VM вручную.</small>
|
|
||||||
</label>'''
|
|
||||||
|
|
||||||
if old in text:
|
|
||||||
text = text.replace(old, new, 1)
|
|
||||||
template_path.write_text(text)
|
|
||||||
print('network manual IP field added')
|
|
||||||
elif 'name="guest_ip" value="auto" placeholder="auto' in text:
|
|
||||||
print('network manual IP field already present')
|
|
||||||
else:
|
|
||||||
raise SystemExit('guest_ip marker not found in network.html')
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
helper = r'''
|
|
||||||
|
|
||||||
def safe_network_template(request: Request, error: str | None = None, status_code: int = 200, diagnostics: dict[str, Any] | None = None):
|
|
||||||
try:
|
|
||||||
ctx = network_core.network_context()
|
|
||||||
except Exception as exc:
|
|
||||||
ctx = {
|
|
||||||
"nat": {
|
|
||||||
"name": network_core.NETWORK_NAME,
|
|
||||||
"bridge": network_core.NAT_BRIDGE,
|
|
||||||
"subnet": network_core.NAT_SUBNET,
|
|
||||||
"gateway": network_core.NAT_GATEWAY,
|
|
||||||
"dhcp": f"{network_core.DHCP_START} - {network_core.DHCP_END}",
|
|
||||||
"exists": False,
|
|
||||||
"info": str(exc),
|
|
||||||
"leases": "",
|
|
||||||
},
|
|
||||||
"networks": [],
|
|
||||||
"forwards": [],
|
|
||||||
"external_interface": "unknown",
|
|
||||||
"ip_forward": "unknown",
|
|
||||||
"nft_rules": "",
|
|
||||||
}
|
|
||||||
error = error or f"Ошибка чтения сетевого состояния: {exc}"
|
|
||||||
return templates.TemplateResponse("network.html", {
|
|
||||||
"request": request,
|
|
||||||
"app_name": APP_NAME,
|
|
||||||
"user": AUTH_USER,
|
|
||||||
"vms": parse_virsh_list(),
|
|
||||||
"ctx": ctx,
|
|
||||||
"error": error,
|
|
||||||
"diagnostics": diagnostics,
|
|
||||||
}, status_code=status_code)
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'def safe_network_template(' not in text:
|
|
||||||
marker = '\n\n@app.get("/network", response_class=HTMLResponse)'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('network route marker not found')
|
|
||||||
text = text.replace(marker, helper + marker, 1)
|
|
||||||
changed.append('safe network template helper added')
|
|
||||||
else:
|
|
||||||
changed.append('safe network template helper already present')
|
|
||||||
|
|
||||||
old_get = '''@app.get("/network", response_class=HTMLResponse)
|
|
||||||
def network_page(request: Request, error: str | None = None):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": error})
|
|
||||||
'''
|
|
||||||
new_get = '''@app.get("/network", response_class=HTMLResponse)
|
|
||||||
def network_page(request: Request, error: str | None = None):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
return safe_network_template(request, error=error)
|
|
||||||
'''
|
|
||||||
if old_get in text:
|
|
||||||
text = text.replace(old_get, new_get, 1)
|
|
||||||
changed.append('network page now uses safe template')
|
|
||||||
elif 'return safe_network_template(request, error=error)' in text:
|
|
||||||
changed.append('network page already safe')
|
|
||||||
else:
|
|
||||||
changed.append('network page replacement skipped')
|
|
||||||
|
|
||||||
old_nat = '''@app.post("/network/nat/setup")
|
|
||||||
def network_nat_setup(request: Request):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
try:
|
|
||||||
network_core.create_nat_network()
|
|
||||||
network_core.apply_port_forwards()
|
|
||||||
except NetworkError as exc:
|
|
||||||
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)
|
|
||||||
return RedirectResponse(url="/network", status_code=303)
|
|
||||||
'''
|
|
||||||
new_nat = '''@app.post("/network/nat/setup")
|
|
||||||
def network_nat_setup(request: Request):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
try:
|
|
||||||
network_core.create_nat_network()
|
|
||||||
network_core.apply_port_forwards()
|
|
||||||
except NetworkError as exc:
|
|
||||||
return safe_network_template(request, error=str(exc), status_code=500)
|
|
||||||
except Exception as exc:
|
|
||||||
return safe_network_template(request, error=f"Внутренняя ошибка настройки NAT: {exc}", status_code=500)
|
|
||||||
return RedirectResponse(url="/network", status_code=303)
|
|
||||||
'''
|
|
||||||
if old_nat in text:
|
|
||||||
text = text.replace(old_nat, new_nat, 1)
|
|
||||||
changed.append('NAT setup catches all exceptions')
|
|
||||||
elif 'Внутренняя ошибка настройки NAT' in text:
|
|
||||||
changed.append('NAT setup already catches all exceptions')
|
|
||||||
else:
|
|
||||||
raise SystemExit('network nat setup route marker not found')
|
|
||||||
|
|
||||||
replacements = {
|
|
||||||
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=400)': 'return safe_network_template(request, error=str(exc), status_code=400)',
|
|
||||||
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)': 'return safe_network_template(request, error=str(exc), status_code=500)',
|
|
||||||
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc), "diagnostics": None}, status_code=400)': 'return safe_network_template(request, error=str(exc), status_code=400)',
|
|
||||||
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": None, "diagnostics": diagnostics})': 'return safe_network_template(request, diagnostics=diagnostics)',
|
|
||||||
}
|
|
||||||
for old, new in replacements.items():
|
|
||||||
if old in text:
|
|
||||||
text = text.replace(old, new)
|
|
||||||
changed.append('network error response hardened')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('network NAT error patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
old_signature = 'def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp"), note: str = Form("")):'
|
|
||||||
new_signature = 'def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: str = Form(...), guest_port: str = Form(...), protocol: str = Form("tcp"), note: str = Form("")):'
|
|
||||||
if old_signature in text:
|
|
||||||
text = text.replace(old_signature, new_signature, 1)
|
|
||||||
app_path.write_text(text)
|
|
||||||
changed.append('app.py forward/add accepts port ranges as text')
|
|
||||||
elif new_signature in text:
|
|
||||||
changed.append('app.py forward/add already accepts port ranges as text')
|
|
||||||
else:
|
|
||||||
raise SystemExit('network_forward_add signature marker not found')
|
|
||||||
|
|
||||||
core_path = app_path.with_name('network_core.py')
|
|
||||||
if core_path.exists():
|
|
||||||
core_text = core_path.read_text()
|
|
||||||
before = core_text
|
|
||||||
|
|
||||||
if 'def iptables_dnat_port_value(' not in core_text:
|
|
||||||
marker = """def iptables_port_value(start: int, end: int) -> str:
|
|
||||||
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
|
|
||||||
|
|
||||||
|
|
||||||
def port_label"""
|
|
||||||
replacement = """def iptables_port_value(start: int, end: int) -> str:
|
|
||||||
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
|
|
||||||
|
|
||||||
|
|
||||||
def iptables_dnat_port_value(start: int, end: int) -> str:
|
|
||||||
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
|
|
||||||
|
|
||||||
|
|
||||||
def successful_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
return [item for item in results if item.get('ok')]
|
|
||||||
|
|
||||||
|
|
||||||
def failed_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
return [item for item in results if not item.get('ok')]
|
|
||||||
|
|
||||||
|
|
||||||
def port_label"""
|
|
||||||
if marker not in core_text:
|
|
||||||
raise SystemExit('network_core.py port helper marker not found')
|
|
||||||
core_text = core_text.replace(marker, replacement, 1)
|
|
||||||
changed.append('network_core.py iptables DNAT range helper added')
|
|
||||||
else:
|
|
||||||
changed.append('network_core.py iptables DNAT range helper already present')
|
|
||||||
|
|
||||||
core_text = core_text.replace(
|
|
||||||
'guest_to = f"{guest_ip}:{iptables_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])}"',
|
|
||||||
'guest_to_port = iptables_dnat_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
|
|
||||||
)
|
|
||||||
core_text = core_text.replace(
|
|
||||||
'guest_to_port = nft_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
|
|
||||||
'guest_to_port = iptables_dnat_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
|
|
||||||
)
|
|
||||||
|
|
||||||
old_apply = """def apply_port_forwards() -> dict[str, Any]:
|
|
||||||
ensure_dirs()
|
|
||||||
enable_ip_forward()
|
|
||||||
disable_rp_filter()
|
|
||||||
items = load_port_forwards()
|
|
||||||
NFT_FILE.write_text(render_nft_rules())
|
|
||||||
run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
|
|
||||||
result = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
|
|
||||||
if not result['ok']:
|
|
||||||
raise NetworkError(result['stderr'] or result['stdout'] or 'Не удалось применить nftables-правила')
|
|
||||||
ufw_results = apply_ufw_route_rules(items)
|
|
||||||
iptables_results = apply_iptables_fallback(items)
|
|
||||||
return {
|
|
||||||
'ok': True,
|
|
||||||
'file': str(NFT_FILE),
|
|
||||||
'rules': render_nft_rules(),
|
|
||||||
'ufw': ufw_results,
|
|
||||||
'iptables': iptables_results,
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
new_apply = """def apply_port_forwards() -> dict[str, Any]:
|
|
||||||
ensure_dirs()
|
|
||||||
enable_ip_forward()
|
|
||||||
disable_rp_filter()
|
|
||||||
items = load_port_forwards()
|
|
||||||
NFT_FILE.write_text(render_nft_rules())
|
|
||||||
|
|
||||||
nft_delete = run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
|
|
||||||
nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
|
|
||||||
ufw_results = apply_ufw_route_rules(items)
|
|
||||||
iptables_results = apply_iptables_fallback(items)
|
|
||||||
|
|
||||||
iptables_ok = bool(successful_results(iptables_results)) or not items
|
|
||||||
if not nft_apply['ok'] and not iptables_ok:
|
|
||||||
details = [
|
|
||||||
nft_apply.get('stderr') or nft_apply.get('stdout') or 'nftables не применился',
|
|
||||||
*[
|
|
||||||
item.get('stderr') or item.get('stdout') or item.get('cmd', 'iptables rule failed')
|
|
||||||
for item in failed_results(iptables_results)
|
|
||||||
],
|
|
||||||
]
|
|
||||||
raise NetworkError('Не удалось применить правила проброса: ' + ' | '.join([d for d in details if d]))
|
|
||||||
|
|
||||||
return {
|
|
||||||
'ok': nft_apply['ok'] or iptables_ok,
|
|
||||||
'file': str(NFT_FILE),
|
|
||||||
'rules': render_nft_rules(),
|
|
||||||
'nft_delete': nft_delete,
|
|
||||||
'nft_apply': nft_apply,
|
|
||||||
'ufw': ufw_results,
|
|
||||||
'iptables': iptables_results,
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
if old_apply in core_text:
|
|
||||||
core_text = core_text.replace(old_apply, new_apply, 1)
|
|
||||||
changed.append('network_core.py apply_port_forwards now falls back to iptables when nft fails')
|
|
||||||
elif 'nft_apply = run_cmd([\'nft\', \'-f\', str(NFT_FILE)]' in core_text or "nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)]" in core_text:
|
|
||||||
changed.append('network_core.py apply_port_forwards fallback already present')
|
|
||||||
else:
|
|
||||||
raise SystemExit('network_core.py apply_port_forwards marker not found')
|
|
||||||
|
|
||||||
core_text = core_text.replace(
|
|
||||||
'f"{vm_ip}:{matching_forward[\'guest_port_label\'].replace(\'-\', \':\')}" in ipt_nat_text',
|
|
||||||
'(f"{vm_ip}:{matching_forward[\'guest_port_label\']}" in ipt_nat_text or f"{vm_ip}:{matching_forward[\'guest_port_label\'].replace(\'-\', \':\')}" in ipt_nat_text)',
|
|
||||||
)
|
|
||||||
|
|
||||||
if core_text != before:
|
|
||||||
core_path.write_text(core_text)
|
|
||||||
changed.append('network_core.py port forwarding rules patched')
|
|
||||||
else:
|
|
||||||
changed.append('network_core.py port forwarding rules already ok')
|
|
||||||
else:
|
|
||||||
changed.append(f'network_core.py not found near {app_path}')
|
|
||||||
|
|
||||||
print('network ranges patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
app_dir = app_path.resolve().parent if app_path.exists() else Path('/opt/virtuality/web')
|
|
||||||
template_path = app_dir / 'templates' / 'vm_detail.html'
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
if not template_path.exists():
|
|
||||||
print(f'legacy boot order cleanup skipped: vm_detail.html not found: {template_path}')
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
tpl = template_path.read_text()
|
|
||||||
original = tpl
|
|
||||||
|
|
||||||
legacy_pattern = re.compile(
|
|
||||||
r'\n\s*<section class="card">\s*\n'
|
|
||||||
r'\s*<div class="card-head">\s*\n'
|
|
||||||
r'\s*<h2>Порядок загрузки</h2>\s*\n'
|
|
||||||
r'\s*<span class="pill">boot order</span>\s*\n'
|
|
||||||
r'.*?'
|
|
||||||
r'\s*</section>\s*\n',
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
|
||||||
|
|
||||||
tpl, count = legacy_pattern.subn('\n', tpl)
|
|
||||||
if count:
|
|
||||||
changed.append(f'legacy select boot-order card removed: {count}')
|
|
||||||
|
|
||||||
# Safety cleanup for any accidental duplicated plain select card without the old pill text.
|
|
||||||
plain_select_pattern = re.compile(
|
|
||||||
r'\n\s*<section class="card">\s*\n'
|
|
||||||
r'\s*<div class="card-head">\s*\n'
|
|
||||||
r'\s*<h2>Порядок загрузки</h2>.*?'
|
|
||||||
r'<select name="boot_order" required>.*?'
|
|
||||||
r'\s*</section>\s*\n',
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
|
||||||
|
|
||||||
tpl, count2 = plain_select_pattern.subn('\n', tpl)
|
|
||||||
if count2:
|
|
||||||
changed.append(f'legacy plain select boot-order card removed: {count2}')
|
|
||||||
|
|
||||||
if tpl != original:
|
|
||||||
template_path.write_text(tpl)
|
|
||||||
else:
|
|
||||||
changed.append('legacy boot-order select card not found')
|
|
||||||
|
|
||||||
print('legacy boot order cleanup patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
templates_dir = app_path.parent / 'templates'
|
|
||||||
if not templates_dir.exists():
|
|
||||||
raise SystemExit(f'templates dir not found: {templates_dir}')
|
|
||||||
|
|
||||||
skip = {'login.html', 'console.html', '_sidebar.html'}
|
|
||||||
changed = []
|
|
||||||
skipped = []
|
|
||||||
|
|
||||||
for path in sorted(templates_dir.glob('*.html')):
|
|
||||||
if path.name in skip:
|
|
||||||
skipped.append(f'{path.name}: skipped system/template')
|
|
||||||
continue
|
|
||||||
text = path.read_text()
|
|
||||||
if '{% include "_sidebar.html" %}' in text or "{% include '_sidebar.html' %}" in text:
|
|
||||||
skipped.append(f'{path.name}: already has sidebar')
|
|
||||||
continue
|
|
||||||
if '<div class="v-layout">' in text:
|
|
||||||
skipped.append(f'{path.name}: already v-layout')
|
|
||||||
continue
|
|
||||||
if '<div class="shell">' not in text:
|
|
||||||
skipped.append(f'{path.name}: no shell')
|
|
||||||
continue
|
|
||||||
|
|
||||||
text = text.replace(
|
|
||||||
'<body>\n <div class="shell">',
|
|
||||||
'<body>\n <div class="v-layout">\n {% include "_sidebar.html" %}\n <main class="v-main">\n <div class="shell v-shell-embedded">',
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Close shell/main/layout before scripts when page has JS block.
|
|
||||||
marker = '\n\n <script>'
|
|
||||||
if marker in text:
|
|
||||||
idx = text.rfind('\n </div>', 0, text.find(marker))
|
|
||||||
if idx != -1:
|
|
||||||
text = text[:idx] + '\n </div>\n </main>\n </div>' + text[idx + len('\n </div>'):]
|
|
||||||
else:
|
|
||||||
skipped.append(f'{path.name}: close marker before script not found')
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
marker2 = '\n</body>'
|
|
||||||
idx = text.rfind('\n </div>', 0, text.find(marker2) if marker2 in text else len(text))
|
|
||||||
if idx != -1:
|
|
||||||
text = text[:idx] + '\n </div>\n </main>\n </div>' + text[idx + len('\n </div>'):]
|
|
||||||
else:
|
|
||||||
skipped.append(f'{path.name}: close marker before body not found')
|
|
||||||
continue
|
|
||||||
|
|
||||||
path.write_text(text)
|
|
||||||
changed.append(path.name)
|
|
||||||
|
|
||||||
print('sidebar layout patch applied:')
|
|
||||||
for name in changed:
|
|
||||||
print(f'- {name}: sidebar wrapped')
|
|
||||||
for item in skipped:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
if 'import update_core' not in text:
|
|
||||||
text = text.replace('import network_core\n', 'import network_core\nimport update_core\n', 1)
|
|
||||||
changed.append('import update_core added')
|
|
||||||
|
|
||||||
helper = r'''
|
|
||||||
|
|
||||||
def dashboard_update_notice() -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
info = update_core.check_updates(fetch=False)
|
|
||||||
if info.get("has_update"):
|
|
||||||
return {
|
|
||||||
"has_update": True,
|
|
||||||
"current_version": info.get("current_version", "unknown"),
|
|
||||||
"latest_version": info.get("latest_version", "unknown"),
|
|
||||||
"missing_count": len(info.get("missing_versions", [])),
|
|
||||||
"commit_count": len(info.get("commits", [])),
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"has_update": False}
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'def dashboard_update_notice() -> dict[str, Any]:' not in text:
|
|
||||||
marker = '\n\ndef update_operation(operation: dict[str, Any], **changes: Any) -> None:'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('update_operation marker not found')
|
|
||||||
text = text.replace(marker, helper + marker, 1)
|
|
||||||
changed.append('dashboard update notice helper added')
|
|
||||||
else:
|
|
||||||
changed.append('dashboard update notice helper already present')
|
|
||||||
|
|
||||||
old_fragment = '"operations": list_operations(5), "operation_css": operation_css})'
|
|
||||||
new_fragment = '"operations": list_operations(5), "operation_css": operation_css, "update_notice": dashboard_update_notice()})'
|
|
||||||
if old_fragment in text:
|
|
||||||
text = text.replace(old_fragment, new_fragment, 1)
|
|
||||||
changed.append('dashboard context gets update_notice')
|
|
||||||
elif '"update_notice": dashboard_update_notice()' in text:
|
|
||||||
changed.append('dashboard context already has update_notice')
|
|
||||||
else:
|
|
||||||
raise SystemExit('dashboard context marker not found')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('update badge patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
if 'import update_core' not in text:
|
|
||||||
if 'import network_core\n' in text:
|
|
||||||
text = text.replace('import network_core\n', 'import network_core\nimport update_core\n', 1)
|
|
||||||
elif 'from network_core import NetworkError\n' in text:
|
|
||||||
text = text.replace('from network_core import NetworkError\n', 'from network_core import NetworkError\nimport update_core\n', 1)
|
|
||||||
else:
|
|
||||||
raise SystemExit('network_core import marker not found')
|
|
||||||
changed.append('import update_core added')
|
|
||||||
else:
|
|
||||||
changed.append('import update_core already present')
|
|
||||||
|
|
||||||
routes = r'''
|
|
||||||
|
|
||||||
@app.get("/update", response_class=HTMLResponse)
|
|
||||||
def update_page(request: Request):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
error = None
|
|
||||||
try:
|
|
||||||
info = update_core.check_updates(fetch=False)
|
|
||||||
except Exception as exc:
|
|
||||||
error = str(exc)
|
|
||||||
info = {
|
|
||||||
"ok": False,
|
|
||||||
"source_dir": str(update_core.SOURCE_DIR),
|
|
||||||
"remote": update_core.REMOTE,
|
|
||||||
"branch": update_core.DEFAULT_BRANCH,
|
|
||||||
"fetch_ok": False,
|
|
||||||
"fetch_error": str(exc),
|
|
||||||
"current_commit": "",
|
|
||||||
"latest_commit": "",
|
|
||||||
"current_version": "unknown",
|
|
||||||
"latest_version": "unknown",
|
|
||||||
"has_update": False,
|
|
||||||
"missing_versions": [],
|
|
||||||
"commits": [],
|
|
||||||
"checked_at": utc_now(),
|
|
||||||
"state": update_core.state(),
|
|
||||||
"log_tail": update_core.update_log_tail(),
|
|
||||||
}
|
|
||||||
return templates.TemplateResponse("update.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/update/status")
|
|
||||||
def update_status(request: Request):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return JSONResponse({"ok": False, "error": "auth required"}, status_code=401)
|
|
||||||
return JSONResponse({
|
|
||||||
"ok": True,
|
|
||||||
"state": update_core.state(),
|
|
||||||
"log_tail": update_core.update_log_tail(260),
|
|
||||||
"checked_at": utc_now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/update/check")
|
|
||||||
def update_check(request: Request):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
try:
|
|
||||||
update_core.check_updates(fetch=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return RedirectResponse(url="/update", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/update/apply")
|
|
||||||
def update_apply(request: Request):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return auth_redirect
|
|
||||||
try:
|
|
||||||
update_core.start_update()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return RedirectResponse(url="/update", status_code=303)
|
|
||||||
'''
|
|
||||||
|
|
||||||
if '@app.get("/update"' not in text:
|
|
||||||
marker = '\n\n@app.get("/operations", response_class=HTMLResponse)'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('operations route marker not found')
|
|
||||||
text = text.replace(marker, routes + marker, 1)
|
|
||||||
changed.append('update routes added')
|
|
||||||
else:
|
|
||||||
changed.append('update route already present')
|
|
||||||
if '@app.get("/update/status"' not in text:
|
|
||||||
marker = '\n\n@app.post("/update/check")'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('update/check route marker not found')
|
|
||||||
status_route = r'''
|
|
||||||
|
|
||||||
@app.get("/update/status")
|
|
||||||
def update_status(request: Request):
|
|
||||||
auth_redirect = require_auth(request)
|
|
||||||
if auth_redirect:
|
|
||||||
return JSONResponse({"ok": False, "error": "auth required"}, status_code=401)
|
|
||||||
return JSONResponse({
|
|
||||||
"ok": True,
|
|
||||||
"state": update_core.state(),
|
|
||||||
"log_tail": update_core.update_log_tail(260),
|
|
||||||
"checked_at": utc_now(),
|
|
||||||
})
|
|
||||||
'''
|
|
||||||
text = text.replace(marker, status_route + marker, 1)
|
|
||||||
changed.append('update status route added')
|
|
||||||
else:
|
|
||||||
changed.append('update status route already present')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('update center patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
safe_name_helper = r'''
|
|
||||||
|
|
||||||
def safe_upload_filename(filename: str, allowed_suffixes: tuple[str, ...], fallback_prefix: str) -> str | None:
|
|
||||||
original = Path(filename or "").name.strip()
|
|
||||||
suffix = Path(original).suffix.lower()
|
|
||||||
if suffix not in allowed_suffixes:
|
|
||||||
return None
|
|
||||||
stem = Path(original).stem.strip()
|
|
||||||
stem = re.sub(r"\s+", "-", stem)
|
|
||||||
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
|
|
||||||
stem = stem.strip("._-")
|
|
||||||
if not stem:
|
|
||||||
stem = fallback_prefix
|
|
||||||
stem = stem[:120]
|
|
||||||
name = f"{stem}{suffix}"
|
|
||||||
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,180}\.[a-zA-Z0-9]{2,8}", name):
|
|
||||||
name = f"{fallback_prefix}{suffix}"
|
|
||||||
return name
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'def safe_upload_filename(' not in text:
|
|
||||||
marker = '\n\ndef safe_iso_filename(filename: str) -> str | None:'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('safe_iso_filename marker not found')
|
|
||||||
text = text.replace(marker, safe_name_helper + marker, 1)
|
|
||||||
changed.append('safe upload filename helper added')
|
|
||||||
else:
|
|
||||||
changed.append('safe upload filename helper already present')
|
|
||||||
|
|
||||||
old_iso = r'''def safe_iso_filename(filename: str) -> str | None:
|
|
||||||
name = Path(filename or "").name.strip().replace(" ", "-")
|
|
||||||
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
|
|
||||||
if not name.lower().endswith(".iso"):
|
|
||||||
return None
|
|
||||||
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.iso", name):
|
|
||||||
return None
|
|
||||||
return name
|
|
||||||
'''
|
|
||||||
new_iso = r'''def safe_iso_filename(filename: str) -> str | None:
|
|
||||||
return safe_upload_filename(filename, (".iso",), "virtuality-iso")
|
|
||||||
'''
|
|
||||||
if old_iso in text:
|
|
||||||
text = text.replace(old_iso, new_iso, 1)
|
|
||||||
changed.append('ISO filename sanitizer relaxed')
|
|
||||||
elif 'return safe_upload_filename(filename, (".iso",), "virtuality-iso")' in text:
|
|
||||||
changed.append('ISO filename sanitizer already relaxed')
|
|
||||||
else:
|
|
||||||
changed.append('ISO filename sanitizer replacement skipped')
|
|
||||||
|
|
||||||
old_disk = r'''def safe_disk_image_filename(filename: str) -> str | None:
|
|
||||||
name = Path(filename or "").name.strip().replace(" ", "-")
|
|
||||||
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
|
|
||||||
if not name.lower().endswith((".img", ".raw", ".qcow2")):
|
|
||||||
return None
|
|
||||||
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(img|raw|qcow2)", name, re.IGNORECASE):
|
|
||||||
return None
|
|
||||||
return name
|
|
||||||
'''
|
|
||||||
new_disk = r'''def safe_disk_image_filename(filename: str) -> str | None:
|
|
||||||
return safe_upload_filename(filename, (".img", ".raw", ".qcow2"), "virtuality-disk")
|
|
||||||
'''
|
|
||||||
if old_disk in text:
|
|
||||||
text = text.replace(old_disk, new_disk, 1)
|
|
||||||
changed.append('disk image filename sanitizer relaxed')
|
|
||||||
elif 'return safe_upload_filename(filename, (".img", ".raw", ".qcow2"), "virtuality-disk")' in text:
|
|
||||||
changed.append('disk image filename sanitizer already relaxed')
|
|
||||||
else:
|
|
||||||
changed.append('disk image filename sanitizer not found yet')
|
|
||||||
|
|
||||||
# Make upload errors more explicit when temp/filesystem fails after multipart parsing.
|
|
||||||
text = text.replace(
|
|
||||||
'"error": f"Ошибка загрузки ISO: {exc}"}, status_code=500)',
|
|
||||||
'"error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)'
|
|
||||||
)
|
|
||||||
text = text.replace(
|
|
||||||
'"error": f"Ошибка загрузки образа: {exc}"}, status_code=500)',
|
|
||||||
'"error": f"Ошибка загрузки образа: {exc}. Проверь свободное место, права на /var/lib/virtuality/disk-images и временный каталог /var/lib/virtuality/tmp."}, status_code=500)'
|
|
||||||
)
|
|
||||||
changed.append('upload error messages clarified')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('upload compatibility patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_dir = Path(sys.argv[1]).resolve().parent if len(sys.argv) > 1 else Path('/opt/virtuality/web')
|
|
||||||
templates_dir = app_dir / 'templates'
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
guard_script = r'''
|
|
||||||
|
|
||||||
function setUploadNavigationGuard(active) {
|
|
||||||
window.virtualityUploadActive = Boolean(active);
|
|
||||||
document.body.classList.toggle('upload-locked', window.virtualityUploadActive);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', function (event) {
|
|
||||||
if (!window.virtualityUploadActive) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = 'Идёт загрузка файла. Переход остановит передачу.';
|
|
||||||
return event.returnValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', function (event) {
|
|
||||||
if (!window.virtualityUploadActive) return;
|
|
||||||
const target = event.target.closest('a, button, input[type="submit"]');
|
|
||||||
if (!target) return;
|
|
||||||
if (target.id === 'iso-upload-button' || target.id === 'disk-upload-button') return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
alert('Идёт загрузка файла. Дождись завершения или нажми «Отменить загрузку». Переход по меню сейчас заблокирован, чтобы файл не оборвался.');
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
document.addEventListener('submit', function (event) {
|
|
||||||
if (!window.virtualityUploadActive) return;
|
|
||||||
if (event.target && (event.target.id === 'iso-upload-form' || event.target.id === 'disk-upload-form')) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
alert('Идёт загрузка файла. Дождись завершения или нажми «Отменить загрузку».');
|
|
||||||
}, true);
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def patch_template(path: Path, active_token: str, form_id: str) -> None:
|
|
||||||
if not path.exists():
|
|
||||||
return
|
|
||||||
text = path.read_text()
|
|
||||||
original = text
|
|
||||||
|
|
||||||
if 'function setUploadNavigationGuard(active)' not in text:
|
|
||||||
marker = ' function bytesText(bytes) {'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit(f'bytesText marker not found in {path}')
|
|
||||||
text = text.replace(marker, guard_script + '\n' + marker, 1)
|
|
||||||
|
|
||||||
text = text.replace(' uploadActive = active;\n', ' uploadActive = active;\n setUploadNavigationGuard(active);\n')
|
|
||||||
|
|
||||||
# During server-side post-processing after upload, file transfer is done, so page navigation is safe again.
|
|
||||||
text = text.replace(" window.location.href = '/iso';", " setUploadNavigationGuard(false);\n window.location.href = '/iso';")
|
|
||||||
text = text.replace(" window.location.href = '/disk-images';", " setUploadNavigationGuard(false);\n window.location.href = '/disk-images';")
|
|
||||||
|
|
||||||
# Conversion polling is not an active browser upload anymore; allow navigation while backend operation continues.
|
|
||||||
text = text.replace(" setUploadMode(false);\n fileInput.disabled = true;", " setUploadMode(false);\n setUploadNavigationGuard(false);\n fileInput.disabled = true;")
|
|
||||||
|
|
||||||
if text != original:
|
|
||||||
path.write_text(text)
|
|
||||||
changed.append(str(path))
|
|
||||||
|
|
||||||
patch_template(templates_dir / 'iso.html', 'iso-upload-button', 'iso-upload-form')
|
|
||||||
patch_template(templates_dir / 'disk_images.html', 'disk-upload-button', 'disk-upload-form')
|
|
||||||
|
|
||||||
print('upload navigation guard patch applied:')
|
|
||||||
for item in changed or ['already applied']:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
helpers = r'''
|
|
||||||
|
|
||||||
def vm_arch_options(profile: dict[str, Any]) -> list[dict[str, str]]:
|
|
||||||
host_arch = str(profile.get("arch", ""))
|
|
||||||
options = [
|
|
||||||
{"value": "auto", "label": "Авто — рекомендовано для этого хоста"},
|
|
||||||
{"value": "x86_64", "label": "x86_64 / amd64 — обычные ПК и серверы"},
|
|
||||||
]
|
|
||||||
if host_arch in ("aarch64", "arm64"):
|
|
||||||
options.append({"value": "aarch64", "label": "ARM64 / aarch64 — KVM на ARM-хосте"})
|
|
||||||
else:
|
|
||||||
options.append({"value": "aarch64", "label": "ARM64 / aarch64 — QEMU-эмуляция, медленно"})
|
|
||||||
options.append({"value": "generic", "label": "Generic QEMU — экспериментально"})
|
|
||||||
return options
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_vm_arch(arch_choice: str, profile: dict[str, Any]) -> str:
|
|
||||||
if arch_choice == "auto" or not arch_choice:
|
|
||||||
return str(profile.get("recommended_guest_arch") or "x86_64")
|
|
||||||
if arch_choice in ("x86_64", "aarch64", "generic"):
|
|
||||||
return arch_choice
|
|
||||||
return str(profile.get("recommended_guest_arch") or "x86_64")
|
|
||||||
|
|
||||||
|
|
||||||
def append_arch_args(cmd: list[str], guest_arch: str, profile: dict[str, Any]) -> list[str]:
|
|
||||||
host_arch = str(profile.get("arch", ""))
|
|
||||||
if guest_arch == "aarch64":
|
|
||||||
if host_arch in ("aarch64", "arm64"):
|
|
||||||
return cmd + ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
|
|
||||||
return cmd + ["--arch", "aarch64", "--machine", "virt", "--cpu", "cortex-a57", "--virt-type", "qemu", "--boot", "uefi"]
|
|
||||||
if guest_arch == "x86_64":
|
|
||||||
if host_arch in ("x86_64", "amd64"):
|
|
||||||
return cmd + ["--arch", "x86_64"]
|
|
||||||
return cmd + ["--arch", "x86_64", "--virt-type", "qemu"]
|
|
||||||
return cmd
|
|
||||||
'''
|
|
||||||
|
|
||||||
start = text.find('def vm_arch_options(profile: dict[str, Any])')
|
|
||||||
if start != -1:
|
|
||||||
end = text.find('\n\ndef vm_form_context(', start)
|
|
||||||
if end == -1:
|
|
||||||
raise SystemExit('vm_form_context end marker not found')
|
|
||||||
text = text[:start] + helpers.strip() + text[end:]
|
|
||||||
changed.append('architecture helpers replaced')
|
|
||||||
else:
|
|
||||||
marker = '\n\ndef vm_form_context('
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('vm_form_context marker not found')
|
|
||||||
text = text.replace(marker, helpers + marker, 1)
|
|
||||||
changed.append('architecture helpers added')
|
|
||||||
|
|
||||||
old_context = '"disk_images": list_disk_image_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
|
|
||||||
new_context = '"disk_images": list_disk_image_files(), "arch_options": vm_arch_options(profile), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
|
|
||||||
if old_context in text:
|
|
||||||
text = text.replace(old_context, new_context, 1)
|
|
||||||
changed.append('vm form context gets arch options')
|
|
||||||
elif '"arch_options": vm_arch_options(profile)' in text:
|
|
||||||
changed.append('vm form context already has arch options')
|
|
||||||
else:
|
|
||||||
old_context2 = '"isos": list_iso_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
|
|
||||||
new_context2 = '"isos": list_iso_files(), "disk_images": list_disk_image_files() if "list_disk_image_files" in globals() else [], "arch_options": vm_arch_options(profile), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
|
|
||||||
if old_context2 in text:
|
|
||||||
text = text.replace(old_context2, new_context2, 1)
|
|
||||||
changed.append('vm form context gets arch options fallback')
|
|
||||||
else:
|
|
||||||
raise SystemExit('vm_form_context payload marker not found')
|
|
||||||
|
|
||||||
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
|
|
||||||
new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
|
|
||||||
if old_sig in text:
|
|
||||||
text = text.replace(old_sig, new_sig, 1)
|
|
||||||
changed.append('vm create signature gets guest_arch')
|
|
||||||
elif new_sig in text:
|
|
||||||
changed.append('vm create signature already has guest_arch')
|
|
||||||
else:
|
|
||||||
old_sig2 = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
|
|
||||||
new_sig2 = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
|
|
||||||
if old_sig2 in text:
|
|
||||||
text = text.replace(old_sig2, new_sig2, 1)
|
|
||||||
changed.append('vm create signature gets guest_arch fallback')
|
|
||||||
else:
|
|
||||||
raise SystemExit('vm_create_submit signature marker not found')
|
|
||||||
|
|
||||||
old_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "network_mode": network_mode, "bridge": bridge}'
|
|
||||||
new_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
|
|
||||||
if old_form in text:
|
|
||||||
text = text.replace(old_form, new_form, 1)
|
|
||||||
changed.append('form state stores guest_arch')
|
|
||||||
elif '"guest_arch": guest_arch' in text:
|
|
||||||
changed.append('form state already stores guest_arch')
|
|
||||||
else:
|
|
||||||
old_form2 = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}'
|
|
||||||
new_form2 = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
|
|
||||||
if old_form2 in text:
|
|
||||||
text = text.replace(old_form2, new_form2, 1)
|
|
||||||
changed.append('form state stores guest_arch fallback')
|
|
||||||
|
|
||||||
old_validation = 'elif source_type not in ("iso", "disk_image"):\n error = "Некорректный источник VM."'
|
|
||||||
new_validation = 'elif source_type not in ("iso", "disk_image"):\n error = "Некорректный источник VM."\n elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."'
|
|
||||||
if old_validation in text and 'Некорректная архитектура VM' not in text:
|
|
||||||
text = text.replace(old_validation, new_validation, 1)
|
|
||||||
changed.append('guest_arch validation added')
|
|
||||||
elif 'Некорректная архитектура VM' in text:
|
|
||||||
changed.append('guest_arch validation already present')
|
|
||||||
|
|
||||||
old_arch_block = ''' profile = host_profile.load_host_profile()
|
|
||||||
is_arm = profile.get("recommended_guest_arch") == "aarch64"
|
|
||||||
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
|
|
||||||
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
|
|
||||||
if is_arm:
|
|
||||||
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
|
|
||||||
'''
|
|
||||||
new_arch_block = ''' profile = host_profile.load_host_profile()
|
|
||||||
resolved_guest_arch = resolve_vm_arch(guest_arch, profile)
|
|
||||||
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
|
|
||||||
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
|
|
||||||
cmd = append_arch_args(cmd, resolved_guest_arch, profile)
|
|
||||||
'''
|
|
||||||
if old_arch_block in text:
|
|
||||||
text = text.replace(old_arch_block, new_arch_block, 1)
|
|
||||||
changed.append('virt-install arch args made selectable')
|
|
||||||
elif 'cmd = append_arch_args(cmd, resolved_guest_arch)' in text:
|
|
||||||
text = text.replace('cmd = append_arch_args(cmd, resolved_guest_arch)', 'cmd = append_arch_args(cmd, resolved_guest_arch, profile)')
|
|
||||||
changed.append('virt-install arch args now use host profile')
|
|
||||||
elif 'cmd = append_arch_args(cmd, resolved_guest_arch, profile)' in text:
|
|
||||||
changed.append('virt-install arch args already use host profile')
|
|
||||||
else:
|
|
||||||
raise SystemExit('arch command block marker not found')
|
|
||||||
|
|
||||||
text = text.replace('"guest_arch": profile.get("recommended_guest_arch"),', '"guest_arch": resolved_guest_arch, "guest_arch_choice": guest_arch,')
|
|
||||||
text = text.replace('"guest_arch": profile.get("recommended_guest_arch")', '"guest_arch": resolved_guest_arch, "guest_arch_choice": guest_arch')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('vm architecture patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
helper = r'''
|
|
||||||
|
|
||||||
def vm_autostart_status(name: str) -> dict[str, str | bool]:
|
|
||||||
result = run_cmd(["virsh", "dominfo", name], timeout=8)
|
|
||||||
output = result.get("stdout", "") or ""
|
|
||||||
match = re.search(r"^Autostart:\s*(.+)$", output, re.MULTILINE | re.IGNORECASE)
|
|
||||||
raw = match.group(1).strip() if match else "unknown"
|
|
||||||
enabled = raw.lower() in ("enable", "enabled", "yes", "on")
|
|
||||||
label = "enabled" if enabled else "disabled" if raw != "unknown" else "unknown"
|
|
||||||
css = "ok" if enabled else "warn"
|
|
||||||
return {"enabled": enabled, "label": label, "css": css, "raw": raw}
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'def vm_autostart_status(' not in text:
|
|
||||||
marker = '\n\ndef parse_virsh_list() -> list[dict[str, str]]:'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('parse_virsh_list marker not found')
|
|
||||||
text = text.replace(marker, helper + marker, 1)
|
|
||||||
changed.append('vm_autostart_status helper added')
|
|
||||||
else:
|
|
||||||
changed.append('vm_autostart_status helper already present')
|
|
||||||
|
|
||||||
old_rows = ''' if len(parts) == 3:\n rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})\n elif len(parts) == 2:\n rows.append({"id": "-", "name": parts[0], "state": parts[1]})'''
|
|
||||||
new_rows = ''' if len(parts) == 3:\n autostart = vm_autostart_status(parts[1])\n rows.append({"id": parts[0], "name": parts[1], "state": parts[2], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})\n elif len(parts) == 2:\n autostart = vm_autostart_status(parts[0])\n rows.append({"id": "-", "name": parts[0], "state": parts[1], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})'''
|
|
||||||
if old_rows in text:
|
|
||||||
text = text.replace(old_rows, new_rows, 1)
|
|
||||||
changed.append('parse_virsh_list enriched with autostart')
|
|
||||||
elif 'autostart_enabled' in text and 'autostart_label' in text:
|
|
||||||
changed.append('parse_virsh_list already has autostart')
|
|
||||||
else:
|
|
||||||
raise SystemExit('parse_virsh_list row marker not found')
|
|
||||||
|
|
||||||
old_details = '''def vm_details(name: str) -> dict[str, Any]:\n return {\n "name": name,\n "dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],\n "vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",\n "ip": vm_ip(name),\n "disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],\n "interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],\n "autostart": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],\n }'''
|
|
||||||
new_details = '''def vm_details(name: str) -> dict[str, Any]:\n dominfo = run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"]\n autostart = vm_autostart_status(name)\n return {\n "name": name,\n "dominfo": dominfo,\n "vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",\n "ip": vm_ip(name),\n "disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],\n "interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],\n "autostart": dominfo,\n "autostart_enabled": autostart["enabled"],\n "autostart_label": autostart["label"],\n "autostart_css": autostart["css"],\n }'''
|
|
||||||
if old_details in text:
|
|
||||||
text = text.replace(old_details, new_details, 1)
|
|
||||||
changed.append('vm_details enriched with autostart')
|
|
||||||
elif '"autostart_enabled": autostart["enabled"]' in text:
|
|
||||||
changed.append('vm_details already has autostart')
|
|
||||||
else:
|
|
||||||
raise SystemExit('vm_details marker not found')
|
|
||||||
|
|
||||||
old_live = ''' vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "—"})'''
|
|
||||||
new_live = ''' vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "—", "autostart_enabled": vm.get("autostart_enabled", False), "autostart_label": vm.get("autostart_label", "unknown"), "autostart_css": vm.get("autostart_css", "warn")})'''
|
|
||||||
if old_live in text:
|
|
||||||
text = text.replace(old_live, new_live, 1)
|
|
||||||
changed.append('live status enriched with autostart')
|
|
||||||
elif '"autostart_label": vm.get("autostart_label", "unknown")' in text:
|
|
||||||
changed.append('live status already has autostart')
|
|
||||||
else:
|
|
||||||
changed.append('live status marker not found, skipped')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('vm autostart patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
app_dir = app_path.resolve().parent
|
|
||||||
template_path = app_dir / 'templates' / 'vm_create.html'
|
|
||||||
changed = []
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
|
|
||||||
helpers = r'''
|
|
||||||
|
|
||||||
def vm_boot_order_options() -> list[dict[str, str]]:
|
|
||||||
return [
|
|
||||||
{"value": "auto", "label": "Auto — по источнику VM"},
|
|
||||||
{"value": "disk", "label": "Сначала диск"},
|
|
||||||
{"value": "cdrom_disk", "label": "Сначала ISO/CD-ROM, потом диск"},
|
|
||||||
{"value": "disk_cdrom", "label": "Сначала диск, потом ISO/CD-ROM"},
|
|
||||||
{"value": "network_disk", "label": "Сначала сеть/PXE, потом диск"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_boot_order(value: str, source_type: str) -> str:
|
|
||||||
value = (value or "auto").strip()
|
|
||||||
if value == "auto":
|
|
||||||
return "cdrom_disk" if source_type == "iso" else "disk"
|
|
||||||
if value in ("disk", "cdrom_disk", "disk_cdrom", "network_disk"):
|
|
||||||
return value
|
|
||||||
return "cdrom_disk" if source_type == "iso" else "disk"
|
|
||||||
|
|
||||||
|
|
||||||
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
|
|
||||||
mapping = {
|
|
||||||
"disk": "hd",
|
|
||||||
"cdrom_disk": "cdrom,hd",
|
|
||||||
"disk_cdrom": "hd,cdrom",
|
|
||||||
"network_disk": "network,hd",
|
|
||||||
}
|
|
||||||
value = mapping.get(boot_order, "hd")
|
|
||||||
if is_arm:
|
|
||||||
return "uefi," + value
|
|
||||||
return value
|
|
||||||
'''
|
|
||||||
|
|
||||||
def replace_once(source: str, old: str, new: str, label: str) -> str:
|
|
||||||
if old in source:
|
|
||||||
changed.append(label)
|
|
||||||
return source.replace(old, new, 1)
|
|
||||||
warnings.append(f'{label}: marker not found, skipped')
|
|
||||||
return source
|
|
||||||
|
|
||||||
if 'def vm_boot_order_options()' not in text:
|
|
||||||
marker = '\n\ndef vm_form_context('
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, helpers + marker, 1)
|
|
||||||
changed.append('boot order helpers added')
|
|
||||||
else:
|
|
||||||
warnings.append('vm_form_context marker not found, helpers skipped')
|
|
||||||
else:
|
|
||||||
changed.append('boot order helpers already present')
|
|
||||||
|
|
||||||
if '"boot_options": vm_boot_order_options()' not in text:
|
|
||||||
if '"arch_options": vm_arch_options(),' in text:
|
|
||||||
text = text.replace('"arch_options": vm_arch_options(),', '"arch_options": vm_arch_options(), "boot_options": vm_boot_order_options(),', 1)
|
|
||||||
changed.append('vm form context gets boot options')
|
|
||||||
else:
|
|
||||||
warnings.append('arch_options marker not found, boot_options skipped')
|
|
||||||
else:
|
|
||||||
changed.append('vm form context already has boot options')
|
|
||||||
|
|
||||||
if '"boot_order": "auto"' not in text:
|
|
||||||
if '"guest_arch": "auto", "network_mode"' in text:
|
|
||||||
text = text.replace('"guest_arch": "auto", "network_mode"', '"guest_arch": "auto", "boot_order": "auto", "network_mode"', 1)
|
|
||||||
changed.append('default form boot_order added')
|
|
||||||
elif '"guest_arch": "auto"' in text:
|
|
||||||
text = text.replace('"guest_arch": "auto"', '"guest_arch": "auto", "boot_order": "auto"', 1)
|
|
||||||
changed.append('default form boot_order added near guest_arch')
|
|
||||||
else:
|
|
||||||
warnings.append('default form marker not found, boot_order default skipped')
|
|
||||||
else:
|
|
||||||
changed.append('default form boot_order already present')
|
|
||||||
|
|
||||||
if 'boot_order: str = Form("auto")' not in text:
|
|
||||||
signature_patterns = [
|
|
||||||
('guest_arch: str = Form("auto"), network_mode:', 'guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode:'),
|
|
||||||
('guest_arch: str = Form("auto"),\n network_mode:', 'guest_arch: str = Form("auto"),\n boot_order: str = Form("auto"),\n network_mode:'),
|
|
||||||
]
|
|
||||||
for old, new in signature_patterns:
|
|
||||||
if old in text:
|
|
||||||
text = text.replace(old, new, 1)
|
|
||||||
changed.append('vm create signature supports boot order')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
warnings.append('vm create signature marker not found, skipped')
|
|
||||||
else:
|
|
||||||
changed.append('vm create signature already supports boot order')
|
|
||||||
|
|
||||||
if '"boot_order": boot_order' not in text:
|
|
||||||
if '"guest_arch": guest_arch, "network_mode"' in text:
|
|
||||||
text = text.replace('"guest_arch": guest_arch, "network_mode"', '"guest_arch": guest_arch, "boot_order": boot_order, "network_mode"', 1)
|
|
||||||
changed.append('vm create form state supports boot order')
|
|
||||||
elif '"guest_arch": guest_arch' in text:
|
|
||||||
text = text.replace('"guest_arch": guest_arch', '"guest_arch": guest_arch, "boot_order": boot_order', 1)
|
|
||||||
changed.append('vm create form state supports boot order near guest_arch')
|
|
||||||
else:
|
|
||||||
warnings.append('vm create form state marker not found, skipped')
|
|
||||||
else:
|
|
||||||
changed.append('vm create form state already supports boot order')
|
|
||||||
|
|
||||||
if 'boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk")' not in text:
|
|
||||||
validation_old = 'elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."'
|
|
||||||
validation_new = validation_old + '\n elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):\n error = "Некорректный порядок загрузки VM."'
|
|
||||||
if validation_old in text:
|
|
||||||
text = text.replace(validation_old, validation_new, 1)
|
|
||||||
changed.append('vm create validation supports boot order')
|
|
||||||
else:
|
|
||||||
warnings.append('vm create validation marker not found, skipped')
|
|
||||||
else:
|
|
||||||
changed.append('vm create validation already supports boot order')
|
|
||||||
|
|
||||||
if 'selected_boot_order = normalize_boot_order(boot_order, source_type)' not in text:
|
|
||||||
if 'selected_arch = normalize_guest_arch(guest_arch, profile)\n' in text:
|
|
||||||
text = text.replace('selected_arch = normalize_guest_arch(guest_arch, profile)\n', 'selected_arch = normalize_guest_arch(guest_arch, profile)\n selected_boot_order = normalize_boot_order(boot_order, source_type)\n', 1)
|
|
||||||
changed.append('selected boot order added')
|
|
||||||
else:
|
|
||||||
warnings.append('selected_arch marker not found, selected boot order skipped')
|
|
||||||
else:
|
|
||||||
changed.append('selected boot order already present')
|
|
||||||
|
|
||||||
# Replace legacy ARM-specific --boot uefi in architecture block with unified --boot argument if present.
|
|
||||||
if ', "--boot", "uefi"]' in text:
|
|
||||||
text = text.replace(', "--boot", "uefi"]', ']')
|
|
||||||
changed.append('legacy ARM --boot uefi removed')
|
|
||||||
|
|
||||||
if 'cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]' not in text:
|
|
||||||
marker = ' if source_type == "disk_image":\n'
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, ' cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]\n\n' + marker, 1)
|
|
||||||
changed.append('virt-install boot argument added')
|
|
||||||
else:
|
|
||||||
warnings.append('source_type command marker not found, boot argument skipped')
|
|
||||||
else:
|
|
||||||
changed.append('virt-install boot argument already present')
|
|
||||||
|
|
||||||
if '"boot_order": selected_boot_order' not in text:
|
|
||||||
if '"guest_arch": selected_arch, "host_profile"' in text:
|
|
||||||
text = text.replace('"guest_arch": selected_arch, "host_profile"', '"guest_arch": selected_arch, "boot_order": selected_boot_order, "host_profile"', 1)
|
|
||||||
changed.append('operation metadata gets boot order')
|
|
||||||
elif '"guest_arch": selected_arch' in text:
|
|
||||||
text = text.replace('"guest_arch": selected_arch', '"guest_arch": selected_arch, "boot_order": selected_boot_order', 1)
|
|
||||||
changed.append('operation metadata gets boot order near guest_arch')
|
|
||||||
else:
|
|
||||||
warnings.append('operation metadata marker not found, skipped')
|
|
||||||
else:
|
|
||||||
changed.append('operation metadata already has boot order')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
|
|
||||||
if template_path.exists():
|
|
||||||
tpl = template_path.read_text()
|
|
||||||
original = tpl
|
|
||||||
|
|
||||||
if 'name="boot_order"' not in tpl:
|
|
||||||
insert = r'''
|
|
||||||
<label>
|
|
||||||
<span>Порядок загрузки</span>
|
|
||||||
<select name="boot_order" required>
|
|
||||||
{% for boot in boot_options %}
|
|
||||||
<option value="{{ boot.value }}" {% if form.boot_order == boot.value %}selected{% endif %}>{{ boot.label }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
'''
|
|
||||||
markers = [
|
|
||||||
' <label>\n <span>Режим сети</span>',
|
|
||||||
' <label>\n <span>Режим сети</span>',
|
|
||||||
]
|
|
||||||
for marker in markers:
|
|
||||||
if marker in tpl:
|
|
||||||
tpl = tpl.replace(marker, insert + '\n' + marker, 1)
|
|
||||||
changed.append('boot order field added to vm_create.html')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
warnings.append('network mode marker not found in vm_create.html, boot field skipped')
|
|
||||||
else:
|
|
||||||
changed.append('boot order field already present in vm_create.html')
|
|
||||||
|
|
||||||
tpl = tpl.replace(
|
|
||||||
'Virtuality импортирует .img/.raw/.qcow2 в новый qcow2-диск VM. Поле “Диск, GB” не используется.',
|
|
||||||
'Virtuality импортирует .img/.raw/.qcow2 в новый qcow2-диск VM. Поле “Диск, GB” не используется. Для готовых дисков обычно выбирай порядок загрузки “Сначала диск”.',
|
|
||||||
)
|
|
||||||
tpl = tpl.replace(
|
|
||||||
'Virtuality создаст новый диск указанного размера и запустит установку с ISO.',
|
|
||||||
'Virtuality создаст новый диск указанного размера и запустит установку с ISO. Для установщика обычно выбирай “Сначала ISO/CD-ROM, потом диск”.',
|
|
||||||
)
|
|
||||||
|
|
||||||
if tpl != original:
|
|
||||||
template_path.write_text(tpl)
|
|
||||||
else:
|
|
||||||
warnings.append(f'vm_create.html not found: {template_path}')
|
|
||||||
|
|
||||||
print('VM boot order patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
if warnings:
|
|
||||||
print('Warnings:')
|
|
||||||
for item in warnings:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
app_dir = app_path.resolve().parent if app_path.exists() else Path('/opt/virtuality/web')
|
|
||||||
template_path = app_dir / 'templates' / 'vm_detail.html'
|
|
||||||
css_path = app_dir / 'static' / 'app.css'
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
if not template_path.exists():
|
|
||||||
print(f'VM detail resource layout patch skipped: vm_detail.html not found: {template_path}')
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
tpl = template_path.read_text()
|
|
||||||
original = tpl
|
|
||||||
|
|
||||||
resource_messages = r'''
|
|
||||||
{% if resource_message %}
|
|
||||||
<div class="alert success">{{ resource_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if resource_error %}
|
|
||||||
<div class="alert danger">{{ resource_error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
'''
|
|
||||||
|
|
||||||
resource_card = r'''
|
|
||||||
<article class="card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h2>CPU / RAM / Архитектура</h2>
|
|
||||||
<span class="pill">resources</span>
|
|
||||||
</div>
|
|
||||||
<div class="resource-mini-grid">
|
|
||||||
<div><span>CPU</span><b>{{ resource_settings.vcpus }}</b></div>
|
|
||||||
<div><span>RAM</span><b>{{ resource_settings.memory_mb }} MB</b></div>
|
|
||||||
<div><span>Архитектура</span><b>{{ resource_settings.arch }}</b></div>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid">
|
|
||||||
<label>
|
|
||||||
<span>CPU, vCPU</span>
|
|
||||||
<input type="number" name="vcpus" min="1" max="128" value="{{ resource_settings.vcpus }}" required {% if not resource_settings.is_shutoff %}disabled{% endif %}>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>RAM, MB</span>
|
|
||||||
<input type="number" name="memory_mb" min="512" max="262144" step="128" value="{{ resource_settings.memory_mb }}" required {% if not resource_settings.is_shutoff %}disabled{% endif %}>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Архитектура</span>
|
|
||||||
<select name="guest_arch" {% if not resource_settings.is_shutoff %}disabled{% endif %}>
|
|
||||||
<option value="keep" selected>Не менять — {{ resource_settings.arch }}</option>
|
|
||||||
<option value="x86_64">x86_64 / amd64</option>
|
|
||||||
<option value="aarch64">ARM64 / aarch64</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button class="primary wide" type="submit" {% if not resource_settings.is_shutoff %}disabled{% endif %}>Применить ресурсы</button>
|
|
||||||
</form>
|
|
||||||
{% if not resource_settings.is_shutoff %}
|
|
||||||
<div class="alert danger">VM сейчас не выключена. CPU, RAM и архитектуру можно менять только после Shutdown/Power off.</div>
|
|
||||||
{% endif %}
|
|
||||||
<p class="muted small-note">CPU/RAM меняются в XML VM. Смена архитектуры требует совместимый диск и загрузчик; x86-диск не станет ARM-диском по щелчку, увы, физика всё ещё душнит.</p>
|
|
||||||
</article>
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'resource_message' not in tpl:
|
|
||||||
marker = ' {% if iso_message %}'
|
|
||||||
if marker in tpl:
|
|
||||||
tpl = tpl.replace(marker, resource_messages + '\n' + marker, 1)
|
|
||||||
changed.append('resource messages added before ISO messages')
|
|
||||||
else:
|
|
||||||
# Safe fallback: place messages after header/topbar area if exact ISO marker is not present.
|
|
||||||
marker = ' <section'
|
|
||||||
index = tpl.find(marker)
|
|
||||||
if index != -1:
|
|
||||||
tpl = tpl[:index] + resource_messages + '\n' + tpl[index:]
|
|
||||||
changed.append('resource messages added by fallback')
|
|
||||||
else:
|
|
||||||
changed.append('resource messages skipped: no safe marker')
|
|
||||||
|
|
||||||
if 'CPU / RAM / Архитектура' not in tpl:
|
|
||||||
candidate_markers = [
|
|
||||||
' <section class="grid three vm-resource-boot-iso-grid">',
|
|
||||||
' <section class="grid two vm-boot-iso-grid">',
|
|
||||||
' <section class="grid two">',
|
|
||||||
' <section class="card">',
|
|
||||||
]
|
|
||||||
inserted = False
|
|
||||||
for marker in candidate_markers:
|
|
||||||
pos = tpl.find(marker)
|
|
||||||
if pos == -1:
|
|
||||||
continue
|
|
||||||
if 'vm-boot-iso-grid' in marker:
|
|
||||||
tpl = tpl.replace(marker, ' <section class="grid three vm-resource-boot-iso-grid">', 1)
|
|
||||||
grid_pos = tpl.find('vm-resource-boot-iso-grid')
|
|
||||||
insert_at = tpl.find('\n <article class="card">', grid_pos)
|
|
||||||
if insert_at != -1:
|
|
||||||
tpl = tpl[:insert_at] + '\n' + resource_card + tpl[insert_at:]
|
|
||||||
else:
|
|
||||||
close_at = tpl.find('</section>', grid_pos)
|
|
||||||
if close_at != -1:
|
|
||||||
tpl = tpl[:close_at] + resource_card + tpl[close_at:]
|
|
||||||
changed.append('resource card inserted into existing settings grid')
|
|
||||||
inserted = True
|
|
||||||
break
|
|
||||||
elif marker == ' <section class="grid two">':
|
|
||||||
settings = '\n\n <section class="grid three vm-resource-boot-iso-grid">\n' + resource_card + ' </section>\n'
|
|
||||||
tpl = tpl.replace('\n\n' + marker, settings + '\n\n' + marker, 1)
|
|
||||||
changed.append('resource grid inserted before first two-column grid')
|
|
||||||
inserted = True
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
settings = '\n\n <section class="grid three vm-resource-boot-iso-grid">\n' + resource_card + ' </section>\n'
|
|
||||||
tpl = tpl[:pos] + settings + tpl[pos:]
|
|
||||||
changed.append('resource grid inserted before first card fallback')
|
|
||||||
inserted = True
|
|
||||||
break
|
|
||||||
if not inserted:
|
|
||||||
changed.append('resource card skipped: no safe layout marker')
|
|
||||||
else:
|
|
||||||
changed.append('resource card already present')
|
|
||||||
|
|
||||||
# Upgrade older two-column settings grid when present.
|
|
||||||
tpl = tpl.replace('class="grid two vm-boot-iso-grid"', 'class="grid three vm-resource-boot-iso-grid"')
|
|
||||||
|
|
||||||
if tpl != original:
|
|
||||||
template_path.write_text(tpl)
|
|
||||||
|
|
||||||
if css_path.exists():
|
|
||||||
css = css_path.read_text()
|
|
||||||
original_css = css
|
|
||||||
if '.three {' not in css:
|
|
||||||
if '.two {' in css:
|
|
||||||
css = css.replace('.two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; }', '.two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; }\n.three { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 12px; }')
|
|
||||||
if '.three {' not in css:
|
|
||||||
css += '\n.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }\n'
|
|
||||||
else:
|
|
||||||
css += '\n.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }\n'
|
|
||||||
changed.append('three-column grid CSS added')
|
|
||||||
if '.resource-mini-grid' not in css:
|
|
||||||
css += '''
|
|
||||||
|
|
||||||
.resource-mini-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin-bottom: 10px; }
|
|
||||||
.resource-mini-grid div { display: grid; gap: 3px; padding: 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); }
|
|
||||||
.resource-mini-grid span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
|
||||||
.resource-mini-grid b { font-size: 14px; overflow-wrap: anywhere; }
|
|
||||||
.boot-order-list { display: grid; gap: 8px; }
|
|
||||||
.boot-order-item { display: flex; align-items: center; gap: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); cursor: grab; }
|
|
||||||
.boot-order-item:active { cursor: grabbing; }
|
|
||||||
.boot-order-item.dragging { opacity: .55; border-color: var(--accent); }
|
|
||||||
.boot-order-item small { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
||||||
.drag-handle { color: var(--muted); font-weight: 900; }
|
|
||||||
@media (max-width: 1180px) { .three { grid-template-columns: 1fr; } .resource-mini-grid { grid-template-columns: 1fr; } }
|
|
||||||
'''
|
|
||||||
changed.append('resource and boot-order CSS added')
|
|
||||||
if css != original_css:
|
|
||||||
css_path.write_text(css)
|
|
||||||
else:
|
|
||||||
changed.append('CSS skipped: app.css not found')
|
|
||||||
|
|
||||||
print('VM detail resource layout patch applied:')
|
|
||||||
for item in changed or ['already applied']:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
helper = r'''
|
|
||||||
|
|
||||||
def vm_exists(name: str) -> bool:
|
|
||||||
if not name or not valid_vm_name(name):
|
|
||||||
return False
|
|
||||||
return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"]
|
|
||||||
'''
|
|
||||||
if 'def vm_exists(name: str) -> bool:' not in text:
|
|
||||||
marker = '\n\ndef list_vms() -> list[dict[str, str]]:'
|
|
||||||
if marker not in text:
|
|
||||||
raise SystemExit('list_vms marker not found')
|
|
||||||
text = text.replace(marker, helper + marker, 1)
|
|
||||||
changed.append('vm_exists helper added')
|
|
||||||
else:
|
|
||||||
changed.append('vm_exists helper already present')
|
|
||||||
|
|
||||||
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
|
|
||||||
new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), replace_existing_disk: str = Form("0"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
|
|
||||||
if old_sig in text:
|
|
||||||
text = text.replace(old_sig, new_sig, 1)
|
|
||||||
changed.append('replace_existing_disk added to signature')
|
|
||||||
elif 'replace_existing_disk: str = Form("0")' in text:
|
|
||||||
changed.append('replace_existing_disk already in signature')
|
|
||||||
else:
|
|
||||||
raise SystemExit('vm_create_submit signature marker not found')
|
|
||||||
|
|
||||||
old_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
|
|
||||||
new_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "replace_existing_disk": replace_existing_disk, "network_mode": network_mode, "bridge": bridge}'
|
|
||||||
if old_form in text:
|
|
||||||
text = text.replace(old_form, new_form, 1)
|
|
||||||
changed.append('replace_existing_disk stored in form')
|
|
||||||
elif '"replace_existing_disk": replace_existing_disk' in text:
|
|
||||||
changed.append('replace_existing_disk already stored in form')
|
|
||||||
|
|
||||||
old_disk_check = ''' disk_path = IMAGES_DIR / f"{name}.qcow2"
|
|
||||||
if disk_path.exists():
|
|
||||||
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
|
|
||||||
|
|
||||||
profile = host_profile.load_host_profile()
|
|
||||||
'''
|
|
||||||
new_disk_check = ''' disk_path = IMAGES_DIR / f"{name}.qcow2"
|
|
||||||
if vm_exists(name):
|
|
||||||
return vm_form_context(request, error=f"VM с именем {name} уже существует. Выбери другое имя или удали существующую VM.", form=form, status_code=400)
|
|
||||||
if disk_path.exists():
|
|
||||||
if replace_existing_disk == "1":
|
|
||||||
disk_path.unlink()
|
|
||||||
else:
|
|
||||||
return vm_form_context(request, error=f"Диск уже существует: {disk_path}. Это может быть остаток неудачной установки. Включи опцию «Заменить существующий диск», если VM с таким именем не нужна.", form=form, status_code=400)
|
|
||||||
|
|
||||||
profile = host_profile.load_host_profile()
|
|
||||||
'''
|
|
||||||
if old_disk_check in text:
|
|
||||||
text = text.replace(old_disk_check, new_disk_check, 1)
|
|
||||||
changed.append('disk replace logic added')
|
|
||||||
elif 'Заменить существующий диск' in text and 'replace_existing_disk == "1"' in text:
|
|
||||||
changed.append('disk replace logic already present')
|
|
||||||
else:
|
|
||||||
raise SystemExit('disk_path exists marker not found')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('vm disk replace patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
changed = []
|
|
||||||
|
|
||||||
helper = '''
|
|
||||||
|
|
||||||
def bridge_exists(name: str) -> bool:
|
|
||||||
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
|
|
||||||
return False
|
|
||||||
return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'def bridge_exists(name: str) -> bool:' not in text:
|
|
||||||
markers = [
|
|
||||||
'\n\ndef default_network_mode() -> str:',
|
|
||||||
'\n\ndef valid_vm_name(name: str) -> bool:',
|
|
||||||
'\n\ndef parse_virsh_list() -> list[dict[str, str]]:',
|
|
||||||
]
|
|
||||||
inserted = False
|
|
||||||
for marker in markers:
|
|
||||||
if marker in text:
|
|
||||||
text = text.replace(marker, helper + marker, 1)
|
|
||||||
changed.append('bridge_exists helper added')
|
|
||||||
inserted = True
|
|
||||||
break
|
|
||||||
if not inserted:
|
|
||||||
print('WARN: bridge helper marker not found, skip helper injection')
|
|
||||||
changed.append('bridge_exists helper skipped')
|
|
||||||
else:
|
|
||||||
changed.append('bridge_exists helper already present')
|
|
||||||
|
|
||||||
old_validation = ''' elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
|
|
||||||
error = "Некорректное имя bridge."
|
|
||||||
else:
|
|
||||||
iso = Path(iso_path).resolve()
|
|
||||||
'''
|
|
||||||
new_validation = ''' elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
|
|
||||||
error = "Некорректное имя bridge."
|
|
||||||
elif network_mode == "bridge" and 'bridge_exists' in globals() and not bridge_exists(bridge):
|
|
||||||
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
|
|
||||||
else:
|
|
||||||
iso = Path(iso_path).resolve()
|
|
||||||
'''
|
|
||||||
|
|
||||||
if old_validation in text:
|
|
||||||
text = text.replace(old_validation, new_validation, 1)
|
|
||||||
changed.append('bridge existence validation added')
|
|
||||||
elif 'Bridge {bridge} не найден на сервере' in text:
|
|
||||||
changed.append('bridge existence validation already present')
|
|
||||||
else:
|
|
||||||
print('WARN: bridge validation marker not found, skip validation injection')
|
|
||||||
changed.append('bridge validation skipped')
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print('vm network guard patch applied:')
|
|
||||||
for item in changed:
|
|
||||||
print(f'- {item}')
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
|
|
||||||
if not app_path.exists():
|
|
||||||
raise SystemExit(f'app.py not found: {app_path}')
|
|
||||||
|
|
||||||
text = app_path.read_text()
|
|
||||||
|
|
||||||
safe_proxy = '''# Virtuality noVNC console patch
|
|
||||||
async def proxy_vnc_to_websocket(reader: asyncio.StreamReader, websocket: WebSocket) -> None:
|
|
||||||
while True:
|
|
||||||
data = await reader.read(65536)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
await websocket.send_bytes(data)
|
|
||||||
except (WebSocketDisconnect, RuntimeError, ConnectionError):
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
async def proxy_websocket_to_vnc(websocket: WebSocket, writer: asyncio.StreamWriter) -> None:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
message = await websocket.receive()
|
|
||||||
except (WebSocketDisconnect, RuntimeError, ConnectionError):
|
|
||||||
break
|
|
||||||
if message.get("type") == "websocket.disconnect":
|
|
||||||
break
|
|
||||||
if message.get("bytes") is not None:
|
|
||||||
writer.write(message["bytes"])
|
|
||||||
elif message.get("text") is not None:
|
|
||||||
writer.write(message["text"].encode())
|
|
||||||
try:
|
|
||||||
await writer.drain()
|
|
||||||
except (RuntimeError, ConnectionError, BrokenPipeError):
|
|
||||||
break
|
|
||||||
'''
|
|
||||||
|
|
||||||
safe_gather = ''' tasks = [
|
|
||||||
asyncio.create_task(proxy_vnc_to_websocket(reader, websocket)),
|
|
||||||
asyncio.create_task(proxy_websocket_to_vnc(websocket, writer)),
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
await asyncio.gather(*pending, return_exceptions=True)
|
|
||||||
await asyncio.gather(*done, return_exceptions=True)
|
|
||||||
finally:
|
|
||||||
writer.close()
|
|
||||||
try:
|
|
||||||
await writer.wait_closed()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'Virtuality noVNC console patch' in text:
|
|
||||||
start = text.find('# Virtuality noVNC console patch')
|
|
||||||
marker = '\n\n@app.get("/login", response_class=HTMLResponse)'
|
|
||||||
end = text.find(marker, start)
|
|
||||||
if start != -1 and end != -1:
|
|
||||||
text = text[:start] + safe_proxy + text[end:]
|
|
||||||
|
|
||||||
old_gather = ''' try:
|
|
||||||
await asyncio.gather(proxy_vnc_to_websocket(reader, websocket), proxy_websocket_to_vnc(websocket, writer))
|
|
||||||
except (WebSocketDisconnect, asyncio.CancelledError, ConnectionError):
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
writer.close()
|
|
||||||
await writer.wait_closed()
|
|
||||||
'''
|
|
||||||
if old_gather in text:
|
|
||||||
text = text.replace(old_gather, safe_gather, 1)
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print(f'noVNC console patch upgraded: {app_path}')
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
text = text.replace('import crypt\n', 'import asyncio\nimport crypt\n', 1)
|
|
||||||
text = text.replace('from fastapi import FastAPI, Request, Form, UploadFile, File\n', 'from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect\n', 1)
|
|
||||||
text = text.replace('serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")\n', 'serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")\nconsole_serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-console")\n', 1)
|
|
||||||
text = text.replace('DEFAULT_BRIDGE = "br0"\n', 'DEFAULT_BRIDGE = "br0"\nNOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)\n', 1)
|
|
||||||
text = text.replace('app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")\n', 'app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")\nif NOVNC_DIR:\n app.mount("/novnc", StaticFiles(directory=str(NOVNC_DIR)), name="novnc")\n', 1)
|
|
||||||
|
|
||||||
old_get_user = '''def get_current_user(request: Request) -> str | None:\n token = request.cookies.get("virtuality_session")\n if not token:\n return None\n try:\n data = serializer.loads(token)\n except BadSignature:\n return None\n return AUTH_USER if data.get("user") == AUTH_USER else None\n'''
|
|
||||||
new_get_user = '''def user_from_session_token(token: str | None) -> str | None:\n if not token:\n return None\n try:\n data = serializer.loads(token)\n except BadSignature:\n return None\n return AUTH_USER if data.get("user") == AUTH_USER else None\n\n\ndef get_current_user(request: Request) -> str | None:\n return user_from_session_token(request.cookies.get("virtuality_session"))\n'''
|
|
||||||
text = text.replace(old_get_user, new_get_user, 1)
|
|
||||||
|
|
||||||
insert_after_vm_ip = '''def vm_vnc_display(name: str) -> str:\n return run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available"\n\n\ndef vnc_display_to_port(display: str) -> int | None:\n value = (display or "").strip()\n if not value or value == "not available":\n return None\n match = re.search(r":(\\d+)$", value)\n if not match:\n return None\n display_number = int(match.group(1))\n if display_number >= 5900:\n return display_number\n return 5900 + display_number\n\n\ndef console_info(name: str) -> dict[str, Any]:\n display = vm_vnc_display(name)\n port = vnc_display_to_port(display)\n has_novnc = bool(NOVNC_DIR and (NOVNC_DIR / "vnc.html").exists())\n token = None\n url = None\n if port and has_novnc:\n token = console_serializer.dumps({"vm": name, "port": port})\n url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=console/ws/{token}"\n return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url}\n\n\n'''
|
|
||||||
text = text.replace('def vm_details(name: str) -> dict[str, Any]:\n', insert_after_vm_ip + 'def vm_details(name: str) -> dict[str, Any]:\n', 1)
|
|
||||||
text = text.replace('"vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",', '"vnc": vm_vnc_display(name),', 1)
|
|
||||||
text = text.replace('@app.get("/login", response_class=HTMLResponse)\n', safe_proxy + '\n@app.get("/login", response_class=HTMLResponse)\n', 1)
|
|
||||||
|
|
||||||
console_routes = '''\n\n@app.get("/vm/{name}/console", response_class=HTMLResponse)\ndef vm_console_page(request: Request, name: str):\n auth_redirect = require_auth(request)\n if auth_redirect:\n return auth_redirect\n if not valid_vm_name(name) or not vm_exists(name):\n return RedirectResponse(url="/", status_code=303)\n return templates.TemplateResponse("console.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})\n\n\n@app.websocket("/console/ws/{token}")\nasync def console_websocket(websocket: WebSocket, token: str):\n if user_from_session_token(websocket.cookies.get("virtuality_session")) != AUTH_USER:\n await websocket.close(code=1008)\n return\n try:\n payload = console_serializer.loads(token)\n vm_name = payload.get("vm")\n target_port = int(payload.get("port"))\n except Exception:\n await websocket.close(code=1008)\n return\n if not valid_vm_name(vm_name) or not vm_exists(vm_name) or target_port < 5900 or target_port > 5999:\n await websocket.close(code=1008)\n return\n await websocket.accept()\n try:\n reader, writer = await asyncio.open_connection("127.0.0.1", target_port)\n except Exception:\n await websocket.close(code=1011)\n return\n''' + safe_gather.replace('\n', '\n')
|
|
||||||
|
|
||||||
text = text.replace('\n\n@app.get("/vm/{name}", response_class=HTMLResponse)\n', console_routes + '\n\n@app.get("/vm/{name}", response_class=HTMLResponse)\n', 1)
|
|
||||||
|
|
||||||
app_path.write_text(text)
|
|
||||||
print(f'noVNC console patch applied: {app_path}')
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
WEB_DIR = Path(__file__).resolve().parent.parent / "web"
|
||||||
|
sys.path.insert(0, str(WEB_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate_dirs(tmp_path, monkeypatch):
|
||||||
|
"""Уводим все записи на диск в tmp, чтобы тесты работали без root и не трогали систему."""
|
||||||
|
import app
|
||||||
|
import backup_core
|
||||||
|
import cloud_images
|
||||||
|
|
||||||
|
monkeypatch.setattr(app, "OPERATIONS_DIR", tmp_path / "ops")
|
||||||
|
monkeypatch.setattr(app, "ISO_DIR", tmp_path / "iso")
|
||||||
|
monkeypatch.setattr(app, "IMAGES_DIR", tmp_path / "images")
|
||||||
|
monkeypatch.setattr(app, "DISK_IMAGES_DIR", tmp_path / "disk-images")
|
||||||
|
monkeypatch.setattr(app, "VM_TEMPLATES_FILE", tmp_path / "config" / "vm_templates.json")
|
||||||
|
monkeypatch.setattr(backup_core, "BACKUPS_DIR", tmp_path / "backups")
|
||||||
|
monkeypatch.setattr(backup_core, "SCHEDULE_FILE", tmp_path / "config" / "backup_schedule.json")
|
||||||
|
monkeypatch.setattr(backup_core, "LOG_FILE", tmp_path / "log" / "backup.log")
|
||||||
|
monkeypatch.setattr(cloud_images, "CLOUD_IMAGES_DIR", tmp_path / "cloud-images")
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def auth_client():
|
||||||
|
"""TestClient с валидной сессией и CSRF-токеном."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
import app
|
||||||
|
|
||||||
|
client = TestClient(app.app)
|
||||||
|
token = app.serializer.dumps({"user": app.AUTH_USER, "csrf": "test-csrf-token"})
|
||||||
|
client.cookies.set("virtuality_session", token)
|
||||||
|
return client
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_page_renders(auth_client):
|
||||||
|
response = auth_client.get("/login", follow_redirects=False)
|
||||||
|
# авторизованный пользователь уводится на дашборд
|
||||||
|
assert response.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_post_redirects_to_login():
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
client = TestClient(app.app)
|
||||||
|
response = client.post("/host/refresh", follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_without_csrf_rejected(auth_client):
|
||||||
|
response = auth_client.post("/iso/refresh", follow_redirects=False)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_with_wrong_csrf_rejected(auth_client):
|
||||||
|
response = auth_client.post("/iso/refresh", data={"csrf_token": "wrong"}, follow_redirects=False)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_with_valid_csrf_accepted(auth_client):
|
||||||
|
response = auth_client.post("/iso/refresh", data={"csrf_token": "test-csrf-token"}, follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_renders_with_csrf_inputs(auth_client):
|
||||||
|
response = auth_client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'name="csrf_token"' in response.text
|
||||||
|
assert "cpu-spark" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_backups_page_renders(auth_client):
|
||||||
|
response = auth_client.get("/backups")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_images_page_renders(auth_client):
|
||||||
|
response = auth_client.get("/cloud-images")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_rate_limit():
|
||||||
|
ip = "203.0.113.7"
|
||||||
|
app.reset_login_failures(ip)
|
||||||
|
assert app.login_block_remaining(ip) == 0
|
||||||
|
for _ in range(app.LOGIN_MAX_FAILURES):
|
||||||
|
app.register_login_failure(ip)
|
||||||
|
assert app.login_block_remaining(ip) > 0
|
||||||
|
app.reset_login_failures(ip)
|
||||||
|
assert app.login_block_remaining(ip) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_token_roundtrip():
|
||||||
|
token = app.serializer.dumps({"user": app.AUTH_USER, "csrf": "abc"})
|
||||||
|
assert app.user_from_session_token(token) == app.AUTH_USER
|
||||||
|
assert app.user_from_session_token("garbage") is None
|
||||||
|
assert app.user_from_session_token(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_vm_cannot_start(auth_client, monkeypatch):
|
||||||
|
monkeypatch.setattr(app, "vm_exists", lambda name: True)
|
||||||
|
app.set_vm_template("tpl-vm", True)
|
||||||
|
response = auth_client.post("/vm/tpl-vm/start", data={"csrf_token": "test-csrf-token"}, follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert "clone_error" in response.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_endpoint(auth_client):
|
||||||
|
response = auth_client.get("/live/metrics")
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["ok"]
|
||||||
|
assert "series" in payload
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import backup_core
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_filename_validation():
|
||||||
|
assert backup_core.valid_backup_filename("vm1_20260815_010101.qcow2")
|
||||||
|
assert backup_core.valid_backup_filename("a.qcow2")
|
||||||
|
assert not backup_core.valid_backup_filename("../evil.qcow2")
|
||||||
|
assert not backup_core.valid_backup_filename("x.img")
|
||||||
|
assert not backup_core.valid_backup_filename("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_path_traversal_guard():
|
||||||
|
assert backup_core.backup_path("vm1", "vm1_x.qcow2") is not None
|
||||||
|
assert backup_core.backup_path("vm1", "../../etc/passwd") is None
|
||||||
|
assert backup_core.backup_path("../vm1", "x.qcow2") is None
|
||||||
|
assert backup_core.backup_path("vm1", "nested/x.qcow2") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_backup_path_shape():
|
||||||
|
path = backup_core.new_backup_path("vm1")
|
||||||
|
assert path is not None
|
||||||
|
assert path.name.startswith("vm1_")
|
||||||
|
assert path.suffix == ".qcow2"
|
||||||
|
assert backup_core.new_backup_path("bad name") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_keeps_newest():
|
||||||
|
vm_dir = backup_core.backup_dir_for("vm1")
|
||||||
|
vm_dir.mkdir(parents=True)
|
||||||
|
for index in range(5):
|
||||||
|
item = vm_dir / f"vm1_{index}.qcow2"
|
||||||
|
item.write_text("x")
|
||||||
|
stamp = time.time() - (5 - index) * 60
|
||||||
|
import os
|
||||||
|
os.utime(item, (stamp, stamp))
|
||||||
|
removed = backup_core.rotate_backups("vm1", keep=2)
|
||||||
|
assert len(removed) == 3
|
||||||
|
remaining = sorted(p.name for p in vm_dir.glob("*.qcow2"))
|
||||||
|
assert remaining == ["vm1_3.qcow2", "vm1_4.qcow2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_ignores_bad_input():
|
||||||
|
assert backup_core.rotate_backups("no-such-vm", keep=3) == []
|
||||||
|
assert backup_core.rotate_backups("vm1", keep=0) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_roundtrip():
|
||||||
|
config = backup_core.save_schedule(True, ["vm2", "vm1", "bad name", "vm1"], 99)
|
||||||
|
assert config == {"enabled": True, "vms": ["vm1", "vm2"], "keep": 30}
|
||||||
|
assert backup_core.load_schedule() == config
|
||||||
|
config = backup_core.save_schedule(False, [], 0)
|
||||||
|
assert backup_core.load_schedule() == {"enabled": False, "vms": [], "keep": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_defaults_when_missing():
|
||||||
|
assert backup_core.load_schedule() == {"enabled": False, "vms": [], "keep": backup_core.DEFAULT_KEEP}
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_script_contents():
|
||||||
|
target = Path("/var/lib/virtuality/backups/vm1/vm1_x.qcow2")
|
||||||
|
script = backup_core.build_backup_script("vm1", "/var/lib/virtuality/images/vm1.qcow2", target)
|
||||||
|
assert "virsh suspend vm1" in script
|
||||||
|
assert "qemu-img convert -p -c -O qcow2" in script
|
||||||
|
assert "virsh resume vm1" in script
|
||||||
|
assert f"{target}.part" in script # атомарная запись через .part
|
||||||
|
restore = backup_core.build_restore_script("/var/lib/virtuality/images/vm1.qcow2", target)
|
||||||
|
assert "qemu-img convert -p -O qcow2" in restore
|
||||||
|
assert ".restore" in restore
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_disk_of_parsing(monkeypatch):
|
||||||
|
sample = """ Type Device Target Source
|
||||||
|
------------------------------------------------
|
||||||
|
file disk vda /var/lib/virtuality/images/vm1.qcow2
|
||||||
|
file cdrom sda /var/lib/virtuality/iso/ubuntu.iso
|
||||||
|
"""
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
returncode = 0
|
||||||
|
stdout = sample
|
||||||
|
stderr = ""
|
||||||
|
|
||||||
|
monkeypatch.setattr(backup_core, "_run", lambda cmd, timeout=60: FakeResult())
|
||||||
|
assert backup_core.first_disk_of("vm1") == "/var/lib/virtuality/images/vm1.qcow2"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import cloud_images
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_entries_consistent():
|
||||||
|
keys = [entry["key"] for entry in cloud_images.CATALOG]
|
||||||
|
assert len(keys) == len(set(keys))
|
||||||
|
for entry in cloud_images.CATALOG:
|
||||||
|
assert entry["arch"] in ("x86_64", "aarch64")
|
||||||
|
assert entry["url"].startswith("https://")
|
||||||
|
assert cloud_images.safe_image_filename(entry["filename"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_entry_lookup():
|
||||||
|
assert cloud_images.catalog_entry("ubuntu-24.04-x86_64") is not None
|
||||||
|
assert cloud_images.catalog_entry("nope") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_for_arch_puts_native_first():
|
||||||
|
entries = cloud_images.catalog_for_arch("aarch64")
|
||||||
|
natives = [e["native"] for e in entries]
|
||||||
|
assert natives == sorted(natives, reverse=True)
|
||||||
|
assert entries[0]["arch"] == "aarch64"
|
||||||
|
entries_x86 = cloud_images.catalog_for_arch("x86_64")
|
||||||
|
assert entries_x86[0]["arch"] == "x86_64"
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_image_filename():
|
||||||
|
assert cloud_images.safe_image_filename("ubuntu-24.04-x86_64.qcow2")
|
||||||
|
assert cloud_images.safe_image_filename("img.img")
|
||||||
|
assert cloud_images.safe_image_filename("../../etc/passwd") is None
|
||||||
|
assert cloud_images.safe_image_filename("../escape.qcow2") == "escape.qcow2" # basename-санитизация
|
||||||
|
assert cloud_images.safe_image_filename("evil.iso") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_path_traversal_guard(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(cloud_images, "CLOUD_IMAGES_DIR", tmp_path)
|
||||||
|
assert cloud_images.image_path_by_name("ok.qcow2") == tmp_path / "ok.qcow2"
|
||||||
|
# Каталоги отбрасываются до basename — результат всегда внутри CLOUD_IMAGES_DIR
|
||||||
|
assert cloud_images.image_path_by_name("../escape.qcow2") == tmp_path / "escape.qcow2"
|
||||||
|
assert cloud_images.image_path_by_name("bad name.qcow2") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_cloud_username():
|
||||||
|
assert cloud_images.valid_cloud_username("admin")
|
||||||
|
assert cloud_images.valid_cloud_username("_svc-user")
|
||||||
|
assert not cloud_images.valid_cloud_username("Admin")
|
||||||
|
assert not cloud_images.valid_cloud_username("1abc")
|
||||||
|
assert not cloud_images.valid_cloud_username("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_ssh_key():
|
||||||
|
assert cloud_images.valid_ssh_key("") # ключ необязателен
|
||||||
|
assert cloud_images.valid_ssh_key("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample user@host")
|
||||||
|
assert cloud_images.valid_ssh_key("ssh-rsa AAAAB3NzaC1yc2E=")
|
||||||
|
assert not cloud_images.valid_ssh_key("not a key")
|
||||||
|
assert not cloud_images.valid_ssh_key("ssh-ed25519")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_user_data_with_password():
|
||||||
|
data = cloud_images.build_user_data("vm1", "admin", 'p"ss', "")
|
||||||
|
assert data.startswith("#cloud-config")
|
||||||
|
assert "hostname: vm1" in data
|
||||||
|
assert "name: admin" in data
|
||||||
|
assert "ssh_pwauth: true" in data
|
||||||
|
assert '"p\\"ss"' in data # пароль экранирован как JSON/YAML-строка
|
||||||
|
assert "ssh_authorized_keys" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_user_data_with_key_only():
|
||||||
|
key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample user@host"
|
||||||
|
data = cloud_images.build_user_data("vm1", "admin", "", key)
|
||||||
|
assert "ssh_pwauth: false" in data
|
||||||
|
assert key in data
|
||||||
|
assert "chpasswd" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_meta_data():
|
||||||
|
meta = cloud_images.build_meta_data("vm1")
|
||||||
|
assert "instance-id: virtuality-vm1" in meta
|
||||||
|
assert "local-hostname: vm1" in meta
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_vm_name():
|
||||||
|
assert app.valid_vm_name("ubuntu-test")
|
||||||
|
assert app.valid_vm_name("vm1.local_x")
|
||||||
|
assert not app.valid_vm_name("")
|
||||||
|
assert not app.valid_vm_name("a") # минимум 2 символа
|
||||||
|
assert not app.valid_vm_name("-starts-with-dash")
|
||||||
|
assert not app.valid_vm_name("has space")
|
||||||
|
assert not app.valid_vm_name("x" * 64)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_snapshot_name():
|
||||||
|
assert app.valid_snapshot_name("before-update")
|
||||||
|
assert app.valid_snapshot_name("s1")
|
||||||
|
assert app.valid_snapshot_name("a") # один символ допустим
|
||||||
|
assert not app.valid_snapshot_name("")
|
||||||
|
assert not app.valid_snapshot_name("bad name")
|
||||||
|
assert not app.valid_snapshot_name(".hidden")
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_iso_filename():
|
||||||
|
assert app.safe_iso_filename("ubuntu-24.04.iso") == "ubuntu-24.04.iso"
|
||||||
|
assert app.safe_iso_filename("my image.iso") == "my-image.iso"
|
||||||
|
assert app.safe_iso_filename("../../etc/passwd") is None
|
||||||
|
assert app.safe_iso_filename("evil.qcow2") is None
|
||||||
|
assert app.safe_iso_filename("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_iso_path_traversal_guard(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(app, "ISO_DIR", tmp_path)
|
||||||
|
assert app.iso_path_by_name("ok.iso") == tmp_path / "ok.iso"
|
||||||
|
# Пути с каталогами санитизируются до basename и остаются внутри ISO_DIR
|
||||||
|
assert app.iso_path_by_name("../escape.iso") == tmp_path / "escape.iso"
|
||||||
|
assert app.iso_path_by_name("nested/evil.iso") == tmp_path / "evil.iso"
|
||||||
|
assert app.iso_path_by_name("не-ascii.iso") != tmp_path / "не-ascii.iso" # кириллица заменяется
|
||||||
|
assert app.iso_path_by_name("no-extension") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_parsers():
|
||||||
|
assert app.wget_progress(10, " 512000K .......... 42% 10.5M 2m30s") == 42
|
||||||
|
assert app.wget_progress(50, "no percent here") == 50
|
||||||
|
assert app.wget_progress(10, "100%") == 99 # качаем — не показываем 100 до mv
|
||||||
|
assert app.qemu_convert_progress(0, " (42.00/100%)") == 42
|
||||||
|
assert app.qemu_convert_progress(77, " (42.00/100%)") == 77 # прогресс не откатывается
|
||||||
|
assert app.progress_from_line(10, "Allocating disk...") == 30
|
||||||
|
assert app.progress_from_line(50, "Creating domain...") == 75
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_list_parser(monkeypatch):
|
||||||
|
sample = """ Name Creation Time State
|
||||||
|
--------------------------------------------------------
|
||||||
|
before-update 2026-08-15 12:10:33 +0300 running
|
||||||
|
clean 2026-08-14 09:01:02 +0300 shutoff
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(app, "run_cmd", lambda cmd, timeout=12: {"ok": True, "stdout": sample, "stderr": "", "code": 0, "cmd": ""})
|
||||||
|
snaps = app.list_vm_snapshots("vm1")
|
||||||
|
assert [s["name"] for s in snaps] == ["before-update", "clean"]
|
||||||
|
assert snaps[0]["state"] == "running"
|
||||||
|
assert snaps[0]["created"] == "2026-08-15 12:10:33 +0300"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_action_rejects_bad_name():
|
||||||
|
ok, message = app.snapshot_action("vm1", "create", "bad name")
|
||||||
|
assert not ok
|
||||||
|
assert "Имя снапшота" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_templates_flag(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(app, "VM_TEMPLATES_FILE", tmp_path / "vm_templates.json")
|
||||||
|
assert app.load_vm_templates() == []
|
||||||
|
app.set_vm_template("vm1", True)
|
||||||
|
app.set_vm_template("vm2", True)
|
||||||
|
assert app.is_vm_template("vm1")
|
||||||
|
assert sorted(app.load_vm_templates()) == ["vm1", "vm2"]
|
||||||
|
app.set_vm_template("vm1", False)
|
||||||
|
assert not app.is_vm_template("vm1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ttl_cache(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def producer():
|
||||||
|
calls.append(1)
|
||||||
|
return len(calls)
|
||||||
|
|
||||||
|
app.cache_invalidate()
|
||||||
|
assert app.cached("k1", 60, producer) == 1
|
||||||
|
assert app.cached("k1", 60, producer) == 1
|
||||||
|
assert len(calls) == 1
|
||||||
|
app.cache_invalidate("k1")
|
||||||
|
assert app.cached("k1", 60, producer) == 2
|
||||||
+1911
-68
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Бэкапы дисков VM: сжатые qcow2-копии в /var/lib/virtuality/backups.
|
||||||
|
|
||||||
|
Без внешних зависимостей — модуль используется и веб-панелью, и ночным
|
||||||
|
systemd-таймером (запуск: python3 backup_core.py --scheduled).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
BACKUPS_DIR = Path("/var/lib/virtuality/backups")
|
||||||
|
SCHEDULE_FILE = Path("/var/lib/virtuality/config/backup_schedule.json")
|
||||||
|
SCHEDULE_LOCK = threading.Lock()
|
||||||
|
LOG_FILE = Path("/var/log/virtuality/backup.log")
|
||||||
|
DEFAULT_KEEP = 5
|
||||||
|
|
||||||
|
|
||||||
|
def valid_vm_name(name: str) -> bool:
|
||||||
|
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def valid_backup_filename(name: str) -> bool:
|
||||||
|
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,120}\.qcow2", name or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def backup_dir_for(vm_name: str) -> Path | None:
|
||||||
|
if not valid_vm_name(vm_name):
|
||||||
|
return None
|
||||||
|
path = (BACKUPS_DIR / vm_name).resolve()
|
||||||
|
if BACKUPS_DIR.resolve() not in path.parents:
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def backup_path(vm_name: str, filename: str) -> Path | None:
|
||||||
|
vm_dir = backup_dir_for(vm_name)
|
||||||
|
if vm_dir is None or not valid_backup_filename(filename):
|
||||||
|
return None
|
||||||
|
path = (vm_dir / filename).resolve()
|
||||||
|
if vm_dir not in path.parents:
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def new_backup_path(vm_name: str) -> Path | None:
|
||||||
|
vm_dir = backup_dir_for(vm_name)
|
||||||
|
if vm_dir is None:
|
||||||
|
return None
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
return vm_dir / f"{vm_name}_{stamp}.qcow2"
|
||||||
|
|
||||||
|
|
||||||
|
def list_backups() -> list[dict[str, Any]]:
|
||||||
|
backups = []
|
||||||
|
if not BACKUPS_DIR.exists():
|
||||||
|
return backups
|
||||||
|
for vm_dir in sorted(BACKUPS_DIR.iterdir()):
|
||||||
|
if not vm_dir.is_dir() or not valid_vm_name(vm_dir.name):
|
||||||
|
continue
|
||||||
|
for item in sorted(vm_dir.glob("*.qcow2"), reverse=True):
|
||||||
|
try:
|
||||||
|
stat = item.stat()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
backups.append({
|
||||||
|
"vm": vm_dir.name,
|
||||||
|
"name": item.name,
|
||||||
|
"path": str(item),
|
||||||
|
"size": f"{stat.st_size / 1024 / 1024:.0f} MB",
|
||||||
|
"created": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
|
||||||
|
})
|
||||||
|
return backups
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_backups(vm_name: str, keep: int) -> list[str]:
|
||||||
|
"""Удаляет старые бэкапы сверх keep. Возвращает имена удалённых файлов."""
|
||||||
|
vm_dir = backup_dir_for(vm_name)
|
||||||
|
if vm_dir is None or not vm_dir.exists() or keep < 1:
|
||||||
|
return []
|
||||||
|
files = sorted(vm_dir.glob("*.qcow2"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
removed = []
|
||||||
|
for stale in files[keep:]:
|
||||||
|
try:
|
||||||
|
stale.unlink()
|
||||||
|
removed.append(stale.name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def load_schedule() -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
data = json.loads(SCHEDULE_FILE.read_text())
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {
|
||||||
|
"enabled": bool(data.get("enabled", False)),
|
||||||
|
"vms": [name for name in data.get("vms", []) if isinstance(name, str) and valid_vm_name(name)],
|
||||||
|
"keep": max(1, min(30, int(data.get("keep", DEFAULT_KEEP)))),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"enabled": False, "vms": [], "keep": DEFAULT_KEEP}
|
||||||
|
|
||||||
|
|
||||||
|
def save_schedule(enabled: bool, vms: list[str], keep: int) -> dict[str, Any]:
|
||||||
|
config = {
|
||||||
|
"enabled": bool(enabled),
|
||||||
|
"vms": sorted({name for name in vms if valid_vm_name(name)}),
|
||||||
|
"keep": max(1, min(30, int(keep))),
|
||||||
|
}
|
||||||
|
with SCHEDULE_LOCK:
|
||||||
|
SCHEDULE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = SCHEDULE_FILE.with_suffix(".json.tmp")
|
||||||
|
tmp_path.write_text(json.dumps(config, ensure_ascii=False, indent=2))
|
||||||
|
tmp_path.replace(SCHEDULE_FILE)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def build_backup_script(vm_name: str, disk_path: str, target: Path) -> str:
|
||||||
|
"""Скрипт бэкапа: пауза работающей VM на время копии, сжатый qcow2, resume.
|
||||||
|
|
||||||
|
Для выключенной VM virsh suspend/resume тихо провалятся — это нормально.
|
||||||
|
"""
|
||||||
|
part = f"{target}.part"
|
||||||
|
return (
|
||||||
|
"set -uo pipefail; "
|
||||||
|
f"mkdir -p {target.parent}; "
|
||||||
|
f"virsh suspend {vm_name} >/dev/null 2>&1 && RESUME=1 || RESUME=0; "
|
||||||
|
f"qemu-img convert -p -c -O qcow2 {disk_path} {part}; STATUS=$?; "
|
||||||
|
f"[ \"$RESUME\" = 1 ] && virsh resume {vm_name} >/dev/null 2>&1; "
|
||||||
|
f"[ $STATUS -ne 0 ] && rm -f {part} && exit $STATUS; "
|
||||||
|
f"mv {part} {target}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_restore_script(disk_path: str, backup_file: Path) -> str:
|
||||||
|
part = f"{disk_path}.restore"
|
||||||
|
return (
|
||||||
|
"set -euo pipefail; "
|
||||||
|
f"qemu-img convert -p -O qcow2 {backup_file} {part}; "
|
||||||
|
f"mv {part} {disk_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- CLI-режим для ночного systemd-таймера ----
|
||||||
|
|
||||||
|
def _log(message: str) -> None:
|
||||||
|
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
with LOG_FILE.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(f"[{stamp}] {message}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], timeout: int = 60) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def first_disk_of(vm_name: str) -> str | None:
|
||||||
|
result = _run(["virsh", "domblklist", vm_name, "--details"])
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
for line in result.stdout.splitlines()[2:]:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4 and parts[1] == "disk" and parts[3].endswith(".qcow2"):
|
||||||
|
return parts[3]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_scheduled_backups() -> int:
|
||||||
|
config = load_schedule()
|
||||||
|
if not config["enabled"]:
|
||||||
|
_log("Расписание выключено — выходим.")
|
||||||
|
return 0
|
||||||
|
if not config["vms"]:
|
||||||
|
_log("Расписание включено, но список VM пуст.")
|
||||||
|
return 0
|
||||||
|
failures = 0
|
||||||
|
for vm_name in config["vms"]:
|
||||||
|
disk = first_disk_of(vm_name)
|
||||||
|
if not disk:
|
||||||
|
_log(f"{vm_name}: qcow2-диск не найден, пропуск.")
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
target = new_backup_path(vm_name)
|
||||||
|
if target is None:
|
||||||
|
_log(f"{vm_name}: некорректное имя VM, пропуск.")
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
_log(f"{vm_name}: бэкап {disk} → {target}")
|
||||||
|
result = subprocess.run(["bash", "-c", build_backup_script(vm_name, disk, target)], capture_output=True, text=True, check=False)
|
||||||
|
if result.returncode == 0:
|
||||||
|
removed = rotate_backups(vm_name, config["keep"])
|
||||||
|
_log(f"{vm_name}: готово" + (f", ротация удалила: {', '.join(removed)}" if removed else ""))
|
||||||
|
else:
|
||||||
|
failures += 1
|
||||||
|
_log(f"{vm_name}: ошибка бэкапа (код {result.returncode}): {result.stderr.strip()[:400]}")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--scheduled" in sys.argv:
|
||||||
|
sys.exit(run_scheduled_backups())
|
||||||
|
print("Использование: backup_core.py --scheduled", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Каталог cloud-образов и генерация cloud-init seed для быстрых VM."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CLOUD_IMAGES_DIR = Path("/var/lib/virtuality/cloud-images")
|
||||||
|
|
||||||
|
# Официальные cloud-образы. Для каждого дистрибутива — обе архитектуры,
|
||||||
|
# чтобы Raspberry/Orange Pi качали arm64 без ручного выбора URL.
|
||||||
|
CATALOG: list[dict[str, str]] = [
|
||||||
|
{
|
||||||
|
"key": "ubuntu-24.04-x86_64",
|
||||||
|
"label": "Ubuntu Server 24.04 LTS",
|
||||||
|
"arch": "x86_64",
|
||||||
|
"filename": "ubuntu-24.04-x86_64.qcow2",
|
||||||
|
"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
|
||||||
|
"default_user": "ubuntu",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ubuntu-24.04-aarch64",
|
||||||
|
"label": "Ubuntu Server 24.04 LTS",
|
||||||
|
"arch": "aarch64",
|
||||||
|
"filename": "ubuntu-24.04-aarch64.qcow2",
|
||||||
|
"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
|
||||||
|
"default_user": "ubuntu",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ubuntu-22.04-x86_64",
|
||||||
|
"label": "Ubuntu Server 22.04 LTS",
|
||||||
|
"arch": "x86_64",
|
||||||
|
"filename": "ubuntu-22.04-x86_64.qcow2",
|
||||||
|
"url": "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
|
||||||
|
"default_user": "ubuntu",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ubuntu-22.04-aarch64",
|
||||||
|
"label": "Ubuntu Server 22.04 LTS",
|
||||||
|
"arch": "aarch64",
|
||||||
|
"filename": "ubuntu-22.04-aarch64.qcow2",
|
||||||
|
"url": "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img",
|
||||||
|
"default_user": "ubuntu",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "debian-12-x86_64",
|
||||||
|
"label": "Debian 12 Bookworm",
|
||||||
|
"arch": "x86_64",
|
||||||
|
"filename": "debian-12-x86_64.qcow2",
|
||||||
|
"url": "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2",
|
||||||
|
"default_user": "debian",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "debian-12-aarch64",
|
||||||
|
"label": "Debian 12 Bookworm",
|
||||||
|
"arch": "aarch64",
|
||||||
|
"filename": "debian-12-aarch64.qcow2",
|
||||||
|
"url": "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-arm64.qcow2",
|
||||||
|
"default_user": "debian",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_entry(key: str) -> dict[str, str] | None:
|
||||||
|
for entry in CATALOG:
|
||||||
|
if entry["key"] == key:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_for_arch(host_arch: str) -> list[dict[str, Any]]:
|
||||||
|
"""Каталог с отметкой native — образы родной архитектуры хоста показываются первыми."""
|
||||||
|
native = "aarch64" if host_arch in ("aarch64", "arm64") else "x86_64"
|
||||||
|
entries = [dict(entry, native=entry["arch"] == native, downloaded=(CLOUD_IMAGES_DIR / entry["filename"]).exists()) for entry in CATALOG]
|
||||||
|
return sorted(entries, key=lambda item: (not item["native"], item["label"]))
|
||||||
|
|
||||||
|
|
||||||
|
def safe_image_filename(filename: str) -> str | None:
|
||||||
|
name = Path(filename or "").name.strip()
|
||||||
|
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(qcow2|img)", name):
|
||||||
|
return None
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def image_path_by_name(name: str) -> Path | None:
|
||||||
|
safe_name = safe_image_filename(name)
|
||||||
|
if not safe_name:
|
||||||
|
return None
|
||||||
|
path = (CLOUD_IMAGES_DIR / safe_name).resolve()
|
||||||
|
if CLOUD_IMAGES_DIR.resolve() not in path.parents:
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def list_images() -> list[dict[str, str]]:
|
||||||
|
CLOUD_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
files = []
|
||||||
|
for item in sorted(CLOUD_IMAGES_DIR.glob("*")):
|
||||||
|
if item.suffix.lower() not in (".qcow2", ".img") or item.name.endswith(".part"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
stat = item.stat()
|
||||||
|
size_mb = stat.st_size / 1024 / 1024
|
||||||
|
updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||||
|
except OSError:
|
||||||
|
size_mb = 0
|
||||||
|
updated = "unknown"
|
||||||
|
known = next((entry for entry in CATALOG if entry["filename"] == item.name), None)
|
||||||
|
files.append({
|
||||||
|
"name": item.name,
|
||||||
|
"path": str(item),
|
||||||
|
"size": f"{size_mb:.0f} MB",
|
||||||
|
"updated": updated,
|
||||||
|
"label": known["label"] if known else item.stem,
|
||||||
|
"arch": known["arch"] if known else ("aarch64" if "arm64" in item.name or "aarch64" in item.name else "x86_64"),
|
||||||
|
"default_user": known["default_user"] if known else "user",
|
||||||
|
})
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def valid_cloud_username(value: str) -> bool:
|
||||||
|
return bool(re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", value or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def valid_ssh_key(value: str) -> bool:
|
||||||
|
if not value:
|
||||||
|
return True
|
||||||
|
return bool(re.fullmatch(r"(ssh-(rsa|ed25519|dss)|ecdsa-sha2-[a-z0-9-]+) [A-Za-z0-9+/=]+( [^\r\n]{0,256})?", value.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_data(hostname: str, username: str, password: str, ssh_key: str) -> str:
|
||||||
|
lines = [
|
||||||
|
"#cloud-config",
|
||||||
|
f"hostname: {hostname}",
|
||||||
|
"manage_etc_hosts: true",
|
||||||
|
f"ssh_pwauth: {'true' if password else 'false'}",
|
||||||
|
"users:",
|
||||||
|
f" - name: {username}",
|
||||||
|
" groups: [sudo]",
|
||||||
|
" shell: /bin/bash",
|
||||||
|
' sudo: "ALL=(ALL) NOPASSWD:ALL"',
|
||||||
|
" lock_passwd: false",
|
||||||
|
]
|
||||||
|
if ssh_key:
|
||||||
|
lines.append(" ssh_authorized_keys:")
|
||||||
|
lines.append(f" - {ssh_key.strip()}")
|
||||||
|
if password:
|
||||||
|
lines += [
|
||||||
|
"chpasswd:",
|
||||||
|
" expire: false",
|
||||||
|
" users:",
|
||||||
|
f" - name: {username}",
|
||||||
|
f" password: {json.dumps(password, ensure_ascii=False)}",
|
||||||
|
" type: text",
|
||||||
|
]
|
||||||
|
lines.append("package_update: false")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def build_meta_data(name: str) -> str:
|
||||||
|
return f"instance-id: virtuality-{name}\nlocal-hostname: {name}\n"
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Лёгкий сборщик метрик хоста: кольцевой буфер в памяти, без БД и зависимостей.
|
||||||
|
|
||||||
|
Раз в METRICS_INTERVAL секунд снимаются CPU, память и load average хоста
|
||||||
|
плюс число работающих VM. Буфер хранит около часа истории для спарклайнов
|
||||||
|
на дашборде. Стоимость — одно чтение /proc и один вызов libvirt за цикл.
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import virt_backend
|
||||||
|
|
||||||
|
METRICS_INTERVAL = 15
|
||||||
|
SAMPLES = deque(maxlen=240) # ~1 час при интервале 15 секунд
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
_STARTED = threading.Event()
|
||||||
|
_prev_cpu: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cpu_times() -> tuple[int, int] | None:
|
||||||
|
"""(busy, total) джиффи из /proc/stat."""
|
||||||
|
try:
|
||||||
|
fields = Path("/proc/stat").read_text().splitlines()[0].split()[1:]
|
||||||
|
values = [int(v) for v in fields[:8]]
|
||||||
|
idle = values[3] + values[4] # idle + iowait
|
||||||
|
total = sum(values)
|
||||||
|
return total - idle, total
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cpu_percent() -> float:
|
||||||
|
global _prev_cpu
|
||||||
|
current = _read_cpu_times()
|
||||||
|
if current is None:
|
||||||
|
return 0.0
|
||||||
|
if _prev_cpu is None:
|
||||||
|
_prev_cpu = current
|
||||||
|
return 0.0
|
||||||
|
busy = current[0] - _prev_cpu[0]
|
||||||
|
total = current[1] - _prev_cpu[1]
|
||||||
|
_prev_cpu = current
|
||||||
|
if total <= 0:
|
||||||
|
return 0.0
|
||||||
|
return round(100.0 * busy / total, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _memory_mb() -> tuple[int, int]:
|
||||||
|
"""(used_mb, total_mb) из /proc/meminfo."""
|
||||||
|
try:
|
||||||
|
info = {}
|
||||||
|
for line in Path("/proc/meminfo").read_text().splitlines():
|
||||||
|
key, _, rest = line.partition(":")
|
||||||
|
info[key] = int(rest.split()[0])
|
||||||
|
total = info.get("MemTotal", 0) // 1024
|
||||||
|
available = info.get("MemAvailable", 0) // 1024
|
||||||
|
return max(0, total - available), total
|
||||||
|
except Exception:
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
|
||||||
|
def _load_average() -> float:
|
||||||
|
try:
|
||||||
|
return float(Path("/proc/loadavg").read_text().split()[0])
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _running_vms() -> int:
|
||||||
|
stats = virt_backend.domain_stats()
|
||||||
|
if stats is not None:
|
||||||
|
return len(stats)
|
||||||
|
return -1 # backend недоступен — не считаем через subprocess, чтобы не грузить хост
|
||||||
|
|
||||||
|
|
||||||
|
def collect_sample() -> dict[str, Any]:
|
||||||
|
used_mb, total_mb = _memory_mb()
|
||||||
|
return {
|
||||||
|
"ts": int(time.time()),
|
||||||
|
"cpu": _cpu_percent(),
|
||||||
|
"mem_used_mb": used_mb,
|
||||||
|
"mem_total_mb": total_mb,
|
||||||
|
"mem_percent": round(100.0 * used_mb / total_mb, 1) if total_mb else 0.0,
|
||||||
|
"load1": _load_average(),
|
||||||
|
"vms_running": _running_vms(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _worker() -> None:
|
||||||
|
_cpu_percent() # первая выборка задаёт базу для дельты
|
||||||
|
while True:
|
||||||
|
sample = collect_sample()
|
||||||
|
with _LOCK:
|
||||||
|
SAMPLES.append(sample)
|
||||||
|
time.sleep(METRICS_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
def start() -> None:
|
||||||
|
if _STARTED.is_set():
|
||||||
|
return
|
||||||
|
_STARTED.set()
|
||||||
|
threading.Thread(target=_worker, daemon=True, name="virtuality-metrics").start()
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(limit: int = 240) -> dict[str, Any]:
|
||||||
|
with _LOCK:
|
||||||
|
series = list(SAMPLES)[-limit:]
|
||||||
|
return {
|
||||||
|
"interval": METRICS_INTERVAL,
|
||||||
|
"series": series,
|
||||||
|
"current": series[-1] if series else None,
|
||||||
|
}
|
||||||
+66
-12
@@ -72,6 +72,18 @@ def iptables_port_value(start: int, end: int) -> str:
|
|||||||
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
|
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
|
||||||
|
|
||||||
|
|
||||||
|
def iptables_dnat_port_value(start: int, end: int) -> str:
|
||||||
|
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
|
||||||
|
|
||||||
|
|
||||||
|
def successful_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return [item for item in results if item.get('ok')]
|
||||||
|
|
||||||
|
|
||||||
|
def failed_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return [item for item in results if not item.get('ok')]
|
||||||
|
|
||||||
|
|
||||||
def port_label(start: int, end: int) -> str:
|
def port_label(start: int, end: int) -> str:
|
||||||
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
|
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
|
||||||
|
|
||||||
@@ -128,6 +140,20 @@ def external_interface() -> str:
|
|||||||
return match.group(1) if match else 'eth0'
|
return match.group(1) if match else 'eth0'
|
||||||
|
|
||||||
|
|
||||||
|
def route_interface_for_ip(ip: str) -> str:
|
||||||
|
if not valid_ip(ip):
|
||||||
|
return NAT_BRIDGE
|
||||||
|
result = run_cmd(['ip', 'route', 'get', ip], timeout=5)
|
||||||
|
if not result['ok']:
|
||||||
|
return NAT_BRIDGE
|
||||||
|
match = re.search(r'\bdev\s+([^\s]+)', result['stdout'])
|
||||||
|
return match.group(1) if match else NAT_BRIDGE
|
||||||
|
|
||||||
|
|
||||||
|
def forward_guest_interface(item: dict[str, Any]) -> str:
|
||||||
|
return route_interface_for_ip(str(item.get('guest_ip', '')))
|
||||||
|
|
||||||
|
|
||||||
def ip_forward_state() -> str:
|
def ip_forward_state() -> str:
|
||||||
path = Path('/proc/sys/net/ipv4/ip_forward')
|
path = Path('/proc/sys/net/ipv4/ip_forward')
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -352,12 +378,13 @@ def delete_port_forward(forward_id: str) -> None:
|
|||||||
|
|
||||||
def render_nft_rules() -> str:
|
def render_nft_rules() -> str:
|
||||||
ext = external_interface()
|
ext = external_interface()
|
||||||
|
forwards = load_port_forwards()
|
||||||
lines = [
|
lines = [
|
||||||
'table ip virtuality {',
|
'table ip virtuality {',
|
||||||
' chain prerouting {',
|
' chain prerouting {',
|
||||||
' type nat hook prerouting priority dstnat; policy accept;',
|
' type nat hook prerouting priority dstnat; policy accept;',
|
||||||
]
|
]
|
||||||
for item in load_port_forwards():
|
for item in forwards:
|
||||||
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
|
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
|
||||||
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
|
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
|
||||||
lines.append(f" iifname \"{ext}\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
|
lines.append(f" iifname \"{ext}\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
|
||||||
@@ -366,11 +393,22 @@ def render_nft_rules() -> str:
|
|||||||
' chain postrouting {',
|
' chain postrouting {',
|
||||||
' type nat hook postrouting priority srcnat; policy accept;',
|
' type nat hook postrouting priority srcnat; policy accept;',
|
||||||
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
|
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
|
||||||
|
]
|
||||||
|
for item in forwards:
|
||||||
|
guest_iface = forward_guest_interface(item)
|
||||||
|
lines.append(f' ip daddr {item["guest_ip"]} oifname "{guest_iface}" masquerade')
|
||||||
|
lines += [
|
||||||
' }',
|
' }',
|
||||||
' chain forward {',
|
' chain forward {',
|
||||||
' type filter hook forward priority filter; policy accept;',
|
' type filter hook forward priority filter; policy accept;',
|
||||||
f' ip saddr {NAT_SUBNET} accept',
|
f' ip saddr {NAT_SUBNET} accept',
|
||||||
f' ip daddr {NAT_SUBNET} accept',
|
f' ip daddr {NAT_SUBNET} accept',
|
||||||
|
]
|
||||||
|
for item in forwards:
|
||||||
|
guest_iface = forward_guest_interface(item)
|
||||||
|
lines.append(f' iifname "{ext}" oifname "{guest_iface}" ip daddr {item["guest_ip"]} accept')
|
||||||
|
lines.append(f' iifname "{guest_iface}" oifname "{ext}" ip saddr {item["guest_ip"]} ct state established,related accept')
|
||||||
|
lines += [
|
||||||
' }',
|
' }',
|
||||||
'}',
|
'}',
|
||||||
]
|
]
|
||||||
@@ -392,7 +430,7 @@ def apply_ufw_route_rules(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||||||
cmd = [
|
cmd = [
|
||||||
'ufw', 'route', 'allow',
|
'ufw', 'route', 'allow',
|
||||||
'in', 'on', ext,
|
'in', 'on', ext,
|
||||||
'out', 'on', NAT_BRIDGE,
|
'out', 'on', forward_guest_interface(item),
|
||||||
'to', item['guest_ip'],
|
'to', item['guest_ip'],
|
||||||
'port', guest_port,
|
'port', guest_port,
|
||||||
'proto', item['protocol'],
|
'proto', item['protocol'],
|
||||||
@@ -429,10 +467,13 @@ def apply_iptables_fallback(items: list[dict[str, Any]]) -> list[dict[str, Any]]
|
|||||||
guest_ip = item['guest_ip']
|
guest_ip = item['guest_ip']
|
||||||
external_port = iptables_port_value(item['external_port_start'], item['external_port_end'])
|
external_port = iptables_port_value(item['external_port_start'], item['external_port_end'])
|
||||||
guest_port = iptables_port_value(item['guest_port_start'], item['guest_port_end'])
|
guest_port = iptables_port_value(item['guest_port_start'], item['guest_port_end'])
|
||||||
guest_to = f"{guest_ip}:{iptables_port_value(item['guest_port_start'], item['guest_port_end'])}"
|
guest_to_port = iptables_dnat_port_value(item['guest_port_start'], item['guest_port_end'])
|
||||||
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', NAT_BRIDGE, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
|
guest_to = f"{guest_ip}:{guest_to_port}"
|
||||||
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', NAT_BRIDGE, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
|
guest_iface = forward_guest_interface(item)
|
||||||
|
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
|
||||||
|
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
|
||||||
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
|
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
|
||||||
|
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, '-j', 'MASQUERADE']))
|
||||||
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-s', NAT_SUBNET, '-o', ext, '-j', 'MASQUERADE']))
|
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-s', NAT_SUBNET, '-o', ext, '-j', 'MASQUERADE']))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -443,16 +484,29 @@ def apply_port_forwards() -> dict[str, Any]:
|
|||||||
disable_rp_filter()
|
disable_rp_filter()
|
||||||
items = load_port_forwards()
|
items = load_port_forwards()
|
||||||
NFT_FILE.write_text(render_nft_rules())
|
NFT_FILE.write_text(render_nft_rules())
|
||||||
run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
|
|
||||||
result = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
|
nft_delete = run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
|
||||||
if not result['ok']:
|
nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
|
||||||
raise NetworkError(result['stderr'] or result['stdout'] or 'Не удалось применить nftables-правила')
|
|
||||||
ufw_results = apply_ufw_route_rules(items)
|
ufw_results = apply_ufw_route_rules(items)
|
||||||
iptables_results = apply_iptables_fallback(items)
|
iptables_results = apply_iptables_fallback(items)
|
||||||
|
|
||||||
|
iptables_ok = bool(successful_results(iptables_results)) or not items
|
||||||
|
if not nft_apply['ok'] and not iptables_ok:
|
||||||
|
details = [
|
||||||
|
nft_apply.get('stderr') or nft_apply.get('stdout') or 'nftables не применился',
|
||||||
|
*[
|
||||||
|
item.get('stderr') or item.get('stdout') or item.get('cmd', 'iptables rule failed')
|
||||||
|
for item in failed_results(iptables_results)
|
||||||
|
],
|
||||||
|
]
|
||||||
|
raise NetworkError('Не удалось применить правила проброса: ' + ' | '.join([d for d in details if d]))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'ok': True,
|
'ok': nft_apply['ok'] or iptables_ok,
|
||||||
'file': str(NFT_FILE),
|
'file': str(NFT_FILE),
|
||||||
'rules': render_nft_rules(),
|
'rules': render_nft_rules(),
|
||||||
|
'nft_delete': nft_delete,
|
||||||
|
'nft_apply': nft_apply,
|
||||||
'ufw': ufw_results,
|
'ufw': ufw_results,
|
||||||
'iptables': iptables_results,
|
'iptables': iptables_results,
|
||||||
}
|
}
|
||||||
@@ -505,7 +559,7 @@ def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, pr
|
|||||||
nft_has_range_rule = bool(matching_forward and vm_ip and matching_forward['external_port_label'] in nft_text and f"{vm_ip}:{matching_forward['guest_port_label']}" in nft_text)
|
nft_has_range_rule = bool(matching_forward and vm_ip and matching_forward['external_port_label'] in nft_text and f"{vm_ip}:{matching_forward['guest_port_label']}" in nft_text)
|
||||||
nft_has_rule = nft_has_single_rule or nft_has_range_rule
|
nft_has_rule = nft_has_single_rule or nft_has_range_rule
|
||||||
iptables_has_prerouting = bool(vm_ip and f'--dport {int(external_port)} -j DNAT --to-destination {vm_ip}:{int(guest_port)}' in ipt_nat_text)
|
iptables_has_prerouting = bool(vm_ip and f'--dport {int(external_port)} -j DNAT --to-destination {vm_ip}:{int(guest_port)}' in ipt_nat_text)
|
||||||
iptables_has_range_prerouting = bool(matching_forward and vm_ip and matching_forward['external_port_label'].replace('-', ':') in ipt_nat_text and f"{vm_ip}:{matching_forward['guest_port_label'].replace('-', ':')}" in ipt_nat_text)
|
iptables_has_range_prerouting = bool(matching_forward and vm_ip and matching_forward['external_port_label'].replace('-', ':') in ipt_nat_text and (f"{vm_ip}:{matching_forward['guest_port_label']}" in ipt_nat_text or f"{vm_ip}:{matching_forward['guest_port_label'].replace('-', ':')}" in ipt_nat_text))
|
||||||
iptables_has_prerouting = iptables_has_prerouting or iptables_has_range_prerouting
|
iptables_has_prerouting = iptables_has_prerouting or iptables_has_range_prerouting
|
||||||
iptables_has_forward = bool(vm_ip and f'-d {vm_ip}/32' in ipt_forward_text and (f'--dport {int(guest_port)}' in ipt_forward_text or (matching_forward and matching_forward['guest_port_label'].replace('-', ':') in ipt_forward_text)))
|
iptables_has_forward = bool(vm_ip and f'-d {vm_ip}/32' in ipt_forward_text and (f'--dport {int(guest_port)}' in ipt_forward_text or (matching_forward and matching_forward['guest_port_label'].replace('-', ':') in ipt_forward_text)))
|
||||||
ufw_has_route = bool(vm_ip and vm_ip in ufw_text and (str(int(guest_port)) in ufw_text or (matching_forward and matching_forward['guest_port_label'].replace('-', ':') in ufw_text)))
|
ufw_has_route = bool(vm_ip and vm_ip in ufw_text and (str(int(guest_port)) in ufw_text or (matching_forward and matching_forward['guest_port_label'].replace('-', ':') in ufw_text)))
|
||||||
@@ -545,7 +599,7 @@ def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, pr
|
|||||||
'checks': checks,
|
'checks': checks,
|
||||||
'commands': {
|
'commands': {
|
||||||
'watch_external': f"sudo tcpdump -ni {ext} '{protocol} port {int(external_port)}'",
|
'watch_external': f"sudo tcpdump -ni {ext} '{protocol} port {int(external_port)}'",
|
||||||
'watch_internal': f"sudo tcpdump -ni {NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'",
|
'watch_internal': f"sudo tcpdump -ni {route_interface_for_ip(vm_ip) if vm_ip else NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'",
|
||||||
},
|
},
|
||||||
'raw': {
|
'raw': {
|
||||||
'domifaddr': domifaddr['stdout'] or domifaddr['stderr'],
|
'domifaddr': domifaddr['stdout'] or domifaddr['stderr'],
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.34.0
|
|||||||
jinja2==3.1.5
|
jinja2==3.1.5
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
|
python-pam==2.0.2
|
||||||
|
|||||||
@@ -229,6 +229,9 @@ input[type="file"] { padding: 12px; }
|
|||||||
.notice { color: #bae6fd; background: rgba(56,189,248,.09); border: 1px solid rgba(56,189,248,.24); }
|
.notice { color: #bae6fd; background: rgba(56,189,248,.09); border: 1px solid rgba(56,189,248,.24); }
|
||||||
.alert.danger { color: #fecaca; background: rgba(239,68,68,.10); border: 1px solid rgba(239,68,68,.28); }
|
.alert.danger { color: #fecaca; background: rgba(239,68,68,.10); border: 1px solid rgba(239,68,68,.28); }
|
||||||
.alert.success { color: #bbf7d0; background: rgba(34,197,94,.10); border: 1px solid rgba(34,197,94,.26); }
|
.alert.success { color: #bbf7d0; background: rgba(34,197,94,.10); border: 1px solid rgba(34,197,94,.26); }
|
||||||
|
.alert.warn { color: #fde68a; background: rgba(245,158,11,.10); border: 1px solid rgba(245,158,11,.28); }
|
||||||
|
.sparkline { display: block; width: 100%; height: 60px; }
|
||||||
|
.sparkline polyline { stroke: var(--accent, #38bdf8); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||||
|
|
||||||
.services, .quick-actions, .knowledge-list { display: grid; gap: 10px; }
|
.services, .quick-actions, .knowledge-list { display: grid; gap: 10px; }
|
||||||
.service-row, .big-action, .knowledge-list div, .meta-grid div {
|
.service-row, .big-action, .knowledge-list div, .meta-grid div {
|
||||||
@@ -295,3 +298,26 @@ body.upload-active .v-nav a, body.upload-active .top-meta a, body.upload-active
|
|||||||
table { display: block; overflow-x: auto; border-spacing: 0 8px; }
|
table { display: block; overflow-x: auto; border-spacing: 0 8px; }
|
||||||
.icon-action em { display: none; }
|
.icon-action em { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.resource-mini-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin-bottom: 10px; }
|
||||||
|
.resource-mini-grid div { display: grid; gap: 3px; padding: 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); }
|
||||||
|
.resource-mini-grid span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.resource-mini-grid b { font-size: 14px; overflow-wrap: anywhere; }
|
||||||
|
.boot-order-list { display: grid; gap: 8px; }
|
||||||
|
.boot-order-item { display: flex; align-items: center; gap: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); cursor: grab; }
|
||||||
|
.boot-order-item:active { cursor: grabbing; }
|
||||||
|
.boot-order-item.dragging { opacity: .55; border-color: var(--accent); }
|
||||||
|
.boot-order-item small { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||||
|
.drag-handle { color: var(--muted); font-weight: 900; }
|
||||||
|
@media (max-width: 1180px) { .three { grid-template-columns: 1fr; } .resource-mini-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Virtuality live status + toast layer */
|
||||||
|
#v-toast-wrap { position: fixed; top: 14px; right: 14px; z-index: 10000; display: grid; gap: 8px; width: min(360px, calc(100vw - 28px)); pointer-events: none; }
|
||||||
|
.v-toast { transform: translateY(-8px); opacity: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: #fff; color: var(--text); box-shadow: 0 12px 32px rgba(15,23,42,.18); font-size: 13px; font-weight: 800; transition: .22s ease; }
|
||||||
|
.v-toast.show { transform: translateY(0); opacity: 1; }
|
||||||
|
.v-toast.ok { border-color: rgba(22,163,74,.28); box-shadow: 0 12px 32px rgba(22,163,74,.16); }
|
||||||
|
.v-toast.warn { border-color: rgba(217,119,6,.32); box-shadow: 0 12px 32px rgba(217,119,6,.16); }
|
||||||
|
.v-toast.err { border-color: rgba(220,38,38,.32); box-shadow: 0 12px 32px rgba(220,38,38,.16); }
|
||||||
|
.live-badge { margin-left: 10px; vertical-align: middle; }
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<nav class="v-nav">
|
<nav class="v-nav">
|
||||||
<a data-icon="⌂" class="{{ 'active' if path == '/' else '' }}" href="/">Обзор</a>
|
<a data-icon="⌂" class="{{ 'active' if path == '/' else '' }}" href="/">Обзор</a>
|
||||||
<a data-icon="+" class="{{ 'active' if path.startswith('/vm/create') else '' }}" href="/vm/create">Создать VM</a>
|
<a data-icon="+" class="{{ 'active' if path == '/vm/create' else '' }}" href="/vm/create">Создать VM</a>
|
||||||
<div class="v-nav-group {{ 'open' if path.startswith('/iso') or path.startswith('/disk-images') else '' }}">
|
<a data-icon="☁" class="{{ 'active' if path == '/vm/create-cloud' else '' }}" href="/vm/create-cloud">VM из облака</a>
|
||||||
|
<div class="v-nav-group {{ 'open' if path.startswith('/iso') or path.startswith('/disk-images') or path.startswith('/cloud-images') else '' }}">
|
||||||
<button class="v-nav-group-title" type="button">Образы</button>
|
<button class="v-nav-group-title" type="button">Образы</button>
|
||||||
<a data-icon="◎" class="{{ 'active' if path.startswith('/iso') else '' }}" href="/iso">ISO</a>
|
<a data-icon="◎" class="{{ 'active' if path.startswith('/iso') else '' }}" href="/iso">ISO</a>
|
||||||
<a data-icon="▣" class="{{ 'active' if path.startswith('/disk-images') else '' }}" href="/disk-images">Диски</a>
|
<a data-icon="▣" class="{{ 'active' if path.startswith('/disk-images') else '' }}" href="/disk-images">Диски</a>
|
||||||
|
<a data-icon="☁" class="{{ 'active' if path.startswith('/cloud-images') else '' }}" href="/cloud-images">Cloud</a>
|
||||||
</div>
|
</div>
|
||||||
<a data-icon="⇄" class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
|
<a data-icon="⇄" class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
|
||||||
|
<a data-icon="⛁" class="{{ 'active' if path.startswith('/backups') else '' }}" href="/backups">Бэкапы</a>
|
||||||
<a data-icon="⚙" class="{{ 'active' if path.startswith('/operations') else '' }}" href="/operations">Операции</a>
|
<a data-icon="⚙" class="{{ 'active' if path.startswith('/operations') else '' }}" href="/operations">Операции</a>
|
||||||
<a data-icon="≡" class="{{ 'active' if path.startswith('/logs') else '' }}" href="/logs">Журналы</a>
|
<a data-icon="≡" class="{{ 'active' if path.startswith('/logs') else '' }}" href="/logs">Журналы</a>
|
||||||
<a data-icon="↻" class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
|
<a data-icon="↻" class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Бэкапы — {{ app_name }}</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="v-layout">
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="v-main">
|
||||||
|
<header class="v-topbar">
|
||||||
|
<div>
|
||||||
|
<div class="v-title">Бэкапы</div>
|
||||||
|
<div class="v-subtitle">Сжатые копии дисков VM в /var/lib/virtuality/backups с ротацией и ночным расписанием</div>
|
||||||
|
</div>
|
||||||
|
<div class="top-meta">
|
||||||
|
<span>user: {{ user }}</span>
|
||||||
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if error %}<div class="alert danger">{{ error }}</div>{% endif %}
|
||||||
|
{% if message %}<div class="alert success">{{ message }}</div>{% endif %}
|
||||||
|
|
||||||
|
<section class="grid two">
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Сделать бэкап сейчас</h2>
|
||||||
|
<span class="pill">qemu-img convert -c</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/backups/create" class="form-grid">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<label>
|
||||||
|
<span>Виртуальная машина</span>
|
||||||
|
<select name="vm_name" {% if vms %}required{% endif %}>
|
||||||
|
{% for vm in vms %}
|
||||||
|
<option value="{{ vm.name }}">{{ vm.name }} — {{ vm.state }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="primary wide" {% if not vms %}disabled{% endif %}>Создать бэкап</button>
|
||||||
|
{% if not vms %}<div class="alert danger">VM пока нет.</div>{% endif %}
|
||||||
|
</form>
|
||||||
|
<p class="muted small-note">Работающая VM ставится на паузу на время копирования диска и продолжает работу автоматически. Бэкап сжимается — занимает заметно меньше исходного диска.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Ночное расписание</h2>
|
||||||
|
<span class="pill">{{ 'включено' if schedule.enabled else 'выключено' }}</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/backups/schedule" class="form-grid">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="enabled" value="1" {% if schedule.enabled %}checked{% endif %}>
|
||||||
|
<span>Делать бэкапы каждую ночь (04:30 по Москве)</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Хранить копий на VM</span>
|
||||||
|
<input type="number" name="keep" min="1" max="30" value="{{ schedule.keep }}" required>
|
||||||
|
</label>
|
||||||
|
<div class="label">Какие VM бэкапить</div>
|
||||||
|
{% for vm in vms %}
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="vms" value="{{ vm.name }}" {% if vm.name in schedule.vms %}checked{% endif %}>
|
||||||
|
<span>{{ vm.name }}</span>
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted small-note">VM пока нет — расписание можно настроить после их создания.</div>
|
||||||
|
{% endfor %}
|
||||||
|
<button class="primary wide">Сохранить расписание</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted small-note">Старые копии сверх лимита удаляются автоматически после каждого успешного бэкапа. Журнал ночных бэкапов: /var/log/virtuality/backup.log.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Сохранённые бэкапы</h2>
|
||||||
|
<span class="pill">{{ backups|length }} файлов</span>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>VM</th><th>Файл</th><th>Размер</th><th>Создан</th><th>Действия</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for backup in backups %}
|
||||||
|
<tr>
|
||||||
|
<td class="strong">{{ backup.vm }}</td>
|
||||||
|
<td>{{ backup.name }}</td>
|
||||||
|
<td>{{ backup.size }}</td>
|
||||||
|
<td>{{ backup.created }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<form method="post" action="/backups/{{ backup.vm }}/{{ backup.name }}/restore" onsubmit="return confirm('Восстановить VM {{ backup.vm }} из бэкапа {{ backup.name }}? Текущий диск VM будет перезаписан.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button class="ghost">Восстановить</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/backups/{{ backup.vm }}/{{ backup.name }}/delete" onsubmit="return confirm('Удалить бэкап {{ backup.name }}?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button class="danger">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="muted">Бэкапов пока нет. Сделай первый бэкап кнопкой слева или включи ночное расписание.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted small-note">Восстановление перезаписывает первый диск VM содержимым бэкапа — VM должна быть выключена.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="/static/panel.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Cloud-образы — {{ app_name }}</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="v-layout">
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="v-main">
|
||||||
|
<header class="v-topbar">
|
||||||
|
<div>
|
||||||
|
<div class="v-title">Cloud-образы</div>
|
||||||
|
<div class="v-subtitle">Официальные cloud-образы Ubuntu и Debian для быстрого создания VM без установки с ISO</div>
|
||||||
|
</div>
|
||||||
|
<div class="top-meta">
|
||||||
|
<span>user: {{ user }}</span>
|
||||||
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="grid two">
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Каталог образов</h2>
|
||||||
|
<span class="pill">официальные зеркала</span>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Система</th><th>Архитектура</th><th>Статус</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in catalog %}
|
||||||
|
<tr>
|
||||||
|
<td class="strong">{{ entry.label }}</td>
|
||||||
|
<td><span class="status {{ 'ok' if entry.native else 'warn' }}">{{ entry.arch }}{% if entry.native %} · родная{% endif %}</span></td>
|
||||||
|
<td>{% if entry.downloaded %}<span class="status ok">скачан</span>{% else %}<span class="muted">не скачан</span>{% endif %}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<form method="post" action="/cloud-images/download"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="key" value="{{ entry.key }}">
|
||||||
|
<button class="{{ 'ghost' if entry.downloaded else 'primary' }}">{{ 'Скачать заново' if entry.downloaded else 'Скачать' }}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted small-note">Образы качаются с cloud-images.ubuntu.com и cloud.debian.org в фоне — прогресс виден в разделе «Операции». Для Raspberry / Orange Pi выбирай aarch64.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Зачем это нужно</h2>
|
||||||
|
<span class="pill">VM за минуту</span>
|
||||||
|
</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<div class="big-action static">
|
||||||
|
<strong>Быстрее, чем ISO</strong>
|
||||||
|
<span>Cloud-образ — уже установленная система. VM создаётся копированием диска и стартует сразу, без установщика.</span>
|
||||||
|
</div>
|
||||||
|
<div class="big-action static">
|
||||||
|
<strong>Логин и SSH-ключ сразу</strong>
|
||||||
|
<span>При создании VM задаёшь пользователя, пароль или SSH-ключ — cloud-init настроит их при первом старте.</span>
|
||||||
|
</div>
|
||||||
|
<div class="big-action static">
|
||||||
|
<strong>ARM64 без боли</strong>
|
||||||
|
<span>Для Raspberry Pi и Orange Pi это основной способ создавать гостевые VM.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="link-pill" href="/vm/create-cloud">→ Создать VM из cloud-образа</a>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Скачанные образы</h2>
|
||||||
|
<span class="pill">{{ images|length }} файлов</span>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Файл</th><th>Система</th><th>Архитектура</th><th>Размер</th><th>Обновлён</th><th>Действия</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for image in images %}
|
||||||
|
<tr>
|
||||||
|
<td class="strong">{{ image.name }}</td>
|
||||||
|
<td>{{ image.label }}</td>
|
||||||
|
<td><span class="status ok">{{ image.arch }}</span></td>
|
||||||
|
<td>{{ image.size }}</td>
|
||||||
|
<td>{{ image.updated }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a class="link-pill" href="/vm/create-cloud">Создать VM</a>
|
||||||
|
<form method="post" action="/cloud-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button class="danger">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="muted">Образов пока нет. Скачай Ubuntu или Debian из каталога выше.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="/static/panel.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -54,6 +54,29 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="grid two">
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>CPU хоста</h2>
|
||||||
|
<span class="pill" id="cpu-now">—</span>
|
||||||
|
</div>
|
||||||
|
<svg id="cpu-spark" class="sparkline" viewBox="0 0 300 60" preserveAspectRatio="none" aria-label="График CPU за час">
|
||||||
|
<polyline points="" fill="none"></polyline>
|
||||||
|
</svg>
|
||||||
|
<p class="muted small-note">Загрузка процессора за последний час, обновляется каждые 15 секунд.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Память хоста</h2>
|
||||||
|
<span class="pill" id="mem-now">—</span>
|
||||||
|
</div>
|
||||||
|
<svg id="mem-spark" class="sparkline" viewBox="0 0 300 60" preserveAspectRatio="none" aria-label="График памяти за час">
|
||||||
|
<polyline points="" fill="none"></polyline>
|
||||||
|
</svg>
|
||||||
|
<p class="muted small-note">Использование RAM за последний час. Хвост графика заполняется по мере работы панели.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>Виртуальные машины</h2>
|
<h2>Виртуальные машины</h2>
|
||||||
@@ -76,11 +99,11 @@
|
|||||||
<td><span class="status {{ 'ok' if is_running else 'err' if 'shut' in vm.state else 'warn' }}">{{ vm.state }}</span></td>
|
<td><span class="status {{ 'ok' if is_running else 'err' if 'shut' in vm.state else 'warn' }}">{{ vm.state }}</span></td>
|
||||||
<td class="autostart-cell">
|
<td class="autostart-cell">
|
||||||
{% if vm.autostart_enabled %}
|
{% if vm.autostart_enabled %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="inline-form">
|
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="inline-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="status status-button ok" title="Отключить автозапуск">{{ vm.autostart_label|default('enabled') }}</button>
|
<button class="status status-button ok" title="Отключить автозапуск">{{ vm.autostart_label|default('enabled') }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/autostart" class="inline-form">
|
<form method="post" action="/vm/{{ vm.name }}/autostart" class="inline-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="status status-button err" title="Включить автозапуск">{{ vm.autostart_label|default('disabled') }}</button>
|
<button class="status status-button err" title="Включить автозапуск">{{ vm.autostart_label|default('disabled') }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -90,12 +113,12 @@
|
|||||||
<a class="icon-action" href="/vm/{{ vm.name }}" title="Подробнее"><span>ℹ</span><em>Детали</em></a>
|
<a class="icon-action" href="/vm/{{ vm.name }}" title="Подробнее"><span>ℹ</span><em>Детали</em></a>
|
||||||
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Консоль"><span>▣</span><em>Консоль</em></a>
|
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Консоль"><span>▣</span><em>Консоль</em></a>
|
||||||
{% if is_running %}
|
{% if is_running %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/reboot"><button class="icon-action" title="Перезагрузить"><span>↻</span><em>Reboot</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/reboot"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Перезагрузить"><span>↻</span><em>Reboot</em></button></form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/shutdown"><button class="icon-action" title="Мягко выключить"><span>⏻</span><em>Shutdown</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/shutdown"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Мягко выключить"><span>⏻</span><em>Shutdown</em></button></form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/destroy"><button class="icon-action danger" title="Принудительно выключить"><span>⚡</span><em>Power</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/destroy"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Принудительно выключить"><span>⚡</span><em>Power</em></button></form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/start"><button class="icon-action success" title="Запустить"><span>▶</span><em>Start</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/start"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action success" title="Запустить"><span>▶</span><em>Start</em></button></form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><button class="icon-action danger" title="Удалить VM"><span>🗑</span><em>Delete</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Удалить VM"><span>🗑</span><em>Delete</em></button></form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -185,6 +208,47 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function drawSpark(svgId, values, max) {
|
||||||
|
const svg = document.getElementById(svgId);
|
||||||
|
if (!svg) return;
|
||||||
|
const line = svg.querySelector('polyline');
|
||||||
|
if (!values.length) { line.setAttribute('points', ''); return; }
|
||||||
|
const width = 300, height = 60, pad = 3;
|
||||||
|
const step = values.length > 1 ? (width - pad * 2) / (values.length - 1) : 0;
|
||||||
|
const points = values.map((value, index) => {
|
||||||
|
const x = pad + index * step;
|
||||||
|
const y = height - pad - Math.max(0, Math.min(1, value / max)) * (height - pad * 2);
|
||||||
|
return x.toFixed(1) + ',' + y.toFixed(1);
|
||||||
|
});
|
||||||
|
line.setAttribute('points', points.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMetrics() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/live/metrics', { cache: 'no-store' });
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!payload.ok || !payload.series) return;
|
||||||
|
const cpu = payload.series.map(sample => sample.cpu);
|
||||||
|
const mem = payload.series.map(sample => sample.mem_percent);
|
||||||
|
drawSpark('cpu-spark', cpu, 100);
|
||||||
|
drawSpark('mem-spark', mem, 100);
|
||||||
|
const current = payload.current;
|
||||||
|
if (current) {
|
||||||
|
const cpuNow = document.getElementById('cpu-now');
|
||||||
|
const memNow = document.getElementById('mem-now');
|
||||||
|
if (cpuNow) cpuNow.textContent = current.cpu.toFixed(1) + '% · load ' + current.load1.toFixed(2);
|
||||||
|
if (memNow) memNow.textContent = current.mem_used_mb + ' / ' + current.mem_total_mb + ' MB · ' + current.mem_percent.toFixed(0) + '%';
|
||||||
|
}
|
||||||
|
} catch (error) { /* сеть моргнула — попробуем в следующий цикл */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshMetrics();
|
||||||
|
setInterval(refreshMetrics, 15000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<script src="/static/panel.js" defer></script>
|
<script src="/static/panel.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<h2>Загрузить образ установленной системы</h2>
|
<h2>Загрузить образ установленной системы</h2>
|
||||||
<span class="pill">.img .raw .qcow2 .img.xz .zip .tar.gz</span>
|
<span class="pill">.img .raw .qcow2 .img.xz .zip .tar.gz</span>
|
||||||
</div>
|
</div>
|
||||||
<form id="disk-upload-form" method="post" action="/disk-images/upload" enctype="multipart/form-data" class="form-grid">
|
<form id="disk-upload-form" method="post" action="/disk-images/upload" enctype="multipart/form-data" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label class="upload-box">
|
<label class="upload-box">
|
||||||
<span>Выбери файл готового диска или архив с диском</span>
|
<span>Выбери файл готового диска или архив с диском</span>
|
||||||
<input id="disk-file-input" type="file" name="image_file" accept=".img,.raw,.qcow2,.img.xz,.zip,.tar.gz,.tgz" required>
|
<input id="disk-file-input" type="file" name="image_file" accept=".img,.raw,.qcow2,.img.xz,.zip,.tar.gz,.tgz" required>
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
<td>{{ image.updated }}</td>
|
<td>{{ image.updated }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a class="link-pill" href="/vm/create">Создать VM</a>
|
<a class="link-pill" href="/vm/create">Создать VM</a>
|
||||||
<form method="post" action="/disk-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');">
|
<form method="post" action="/disk-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="danger">Удалить</button>
|
<button class="danger">Удалить</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>Проверки</h2>
|
<h2>Проверки</h2>
|
||||||
<form method="post" action="/host/refresh"><button>Переопределить профиль</button></form>
|
<form method="post" action="/host/refresh"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button>Переопределить профиль</button></form>
|
||||||
</div>
|
</div>
|
||||||
<div class="services">
|
<div class="services">
|
||||||
{% for check in profile.checks %}
|
{% for check in profile.checks %}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<h2>Загрузить установочный ISO</h2>
|
<h2>Загрузить установочный ISO</h2>
|
||||||
<span class="pill">только .iso</span>
|
<span class="pill">только .iso</span>
|
||||||
</div>
|
</div>
|
||||||
<form id="iso-upload-form" method="post" action="/iso/upload" enctype="multipart/form-data" class="form-grid">
|
<form id="iso-upload-form" method="post" action="/iso/upload" enctype="multipart/form-data" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label class="upload-box">
|
<label class="upload-box">
|
||||||
<span>Выбери ISO-файл установочного диска</span>
|
<span>Выбери ISO-файл установочного диска</span>
|
||||||
<input id="iso-file-input" type="file" name="iso_file" accept=".iso" required>
|
<input id="iso-file-input" type="file" name="iso_file" accept=".iso" required>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<span class="pill">pool</span>
|
<span class="pill">pool</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions big-actions">
|
<div class="actions big-actions">
|
||||||
<form method="post" action="/iso/refresh"><button>Обновить ISO pool</button></form>
|
<form method="post" action="/iso/refresh"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button>Обновить ISO pool</button></form>
|
||||||
<a class="link-pill accent" href="/vm/create">Создать VM</a>
|
<a class="link-pill accent" href="/vm/create">Создать VM</a>
|
||||||
</div>
|
</div>
|
||||||
<pre>sudo virsh pool-refresh virtuality-iso
|
<pre>sudo virsh pool-refresh virtuality-iso
|
||||||
@@ -86,7 +86,7 @@ ls -lh /var/lib/virtuality/iso</pre>
|
|||||||
<td>{{ iso.size }}</td>
|
<td>{{ iso.size }}</td>
|
||||||
<td>{{ iso.updated }}</td>
|
<td>{{ iso.updated }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="post" action="/iso/{{ iso.name }}/delete" onsubmit="return confirm('Удалить ISO {{ iso.name }}?');">
|
<form method="post" action="/iso/{{ iso.name }}/delete" onsubmit="return confirm('Удалить ISO {{ iso.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="danger">Удалить</button>
|
<button class="danger">Удалить</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="alert danger">{{ error }}</div>
|
<div class="alert danger">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/login" class="auth-form">
|
<form method="post" action="/login" class="auth-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>Linux-пользователь</span>
|
<span>Linux-пользователь</span>
|
||||||
<input type="text" name="username" value="{{ auth_user }}" autocomplete="username" required>
|
<input type="text" name="username" value="{{ auth_user }}" autocomplete="username" required>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<div><span>DHCP</span><strong>{{ ctx.nat.dhcp }}</strong></div>
|
<div><span>DHCP</span><strong>{{ ctx.nat.dhcp }}</strong></div>
|
||||||
<div><span>Bridge</span><strong>{{ ctx.nat.bridge }}</strong></div>
|
<div><span>Bridge</span><strong>{{ ctx.nat.bridge }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/network/nat/setup" class="inline-form top-space">
|
<form method="post" action="/network/nat/setup" class="inline-form top-space"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="primary">Создать / починить NAT-сеть</button>
|
<button class="primary">Создать / починить NAT-сеть</button>
|
||||||
</form>
|
</form>
|
||||||
<pre>{{ ctx.nat.info }}</pre>
|
<pre>{{ ctx.nat.info }}</pre>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<h2>Добавить проброс порта</h2>
|
<h2>Добавить проброс порта</h2>
|
||||||
<span class="pill">DNAT auto/manual IP · ranges</span>
|
<span class="pill">DNAT auto/manual IP · ranges</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/network/forward/add" class="form-grid">
|
<form method="post" action="/network/forward/add" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>Описание</span>
|
<span>Описание</span>
|
||||||
<input type="text" name="note" placeholder="Например: сайт, SSH, SIP, RTP MikoPBX">
|
<input type="text" name="note" placeholder="Например: сайт, SSH, SIP, RTP MikoPBX">
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
<h2>Диагностика публичного доступа</h2>
|
<h2>Диагностика публичного доступа</h2>
|
||||||
<span class="pill">VM → Internet</span>
|
<span class="pill">VM → Internet</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/network/diagnose" class="form-grid">
|
<form method="post" action="/network/diagnose" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>Виртуальная машина</span>
|
<span>Виртуальная машина</span>
|
||||||
<select name="vm_name" required>
|
<select name="vm_name" required>
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>Пробросы портов</h2>
|
<h2>Пробросы портов</h2>
|
||||||
<form method="post" action="/network/apply"><button>Применить правила</button></form>
|
<form method="post" action="/network/apply"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button>Применить правила</button></form>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -239,14 +239,14 @@
|
|||||||
<td>{{ item.note }}</td>
|
<td>{{ item.note }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" action="/network/diagnose">
|
<form method="post" action="/network/diagnose"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="vm_name" value="{{ item.vm_name }}">
|
<input type="hidden" name="vm_name" value="{{ item.vm_name }}">
|
||||||
<input type="hidden" name="external_port" value="{{ item.external_port }}">
|
<input type="hidden" name="external_port" value="{{ item.external_port }}">
|
||||||
<input type="hidden" name="guest_port" value="{{ item.guest_port }}">
|
<input type="hidden" name="guest_port" value="{{ item.guest_port }}">
|
||||||
<input type="hidden" name="protocol" value="{{ item.protocol }}">
|
<input type="hidden" name="protocol" value="{{ item.protocol }}">
|
||||||
<button>Диагностика</button>
|
<button>Диагностика</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/network/forward/{{ item.id }}/delete"><button class="danger">Удалить</button></form>
|
<form method="post" action="/network/forward/{{ item.id }}/delete"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="danger">Удалить</button></form>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/operations">Операции</a>
|
<a class="link-pill" href="/operations">Операции</a>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -82,11 +82,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="actions top-space update-actions">
|
<div class="actions top-space update-actions">
|
||||||
{% if info.has_update %}
|
{% if info.has_update %}
|
||||||
<form method="post" action="/update/apply" id="update-apply-form" onsubmit="return confirm('Запустить обновление Virtuality из GitHub? Панель перезапустится.');">
|
<form method="post" action="/update/apply" id="update-apply-form" onsubmit="return confirm('Запустить обновление Virtuality из GitHub? Панель перезапустится.');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="primary" id="update-apply-button" type="submit">Установить обновления</button>
|
<button class="primary" id="update-apply-button" type="submit">Установить обновления</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="/update/check" id="update-check-form">
|
<form method="post" action="/update/check" id="update-check-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="primary" id="update-main-button" type="submit">Проверить обновления</button>
|
<button class="primary" id="update-main-button" type="submit">Проверить обновления</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<span class="pill">VM wizard</span>
|
<span class="pill">VM wizard</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/vm/create" class="form-grid">
|
<form method="post" action="/vm/create" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>Имя VM</span>
|
<span>Имя VM</span>
|
||||||
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="ubuntu-test" required>
|
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="ubuntu-test" required>
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>VM из cloud-образа — {{ app_name }}</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="v-layout">
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="v-main">
|
||||||
|
<header class="v-topbar">
|
||||||
|
<div>
|
||||||
|
<div class="v-title">VM из cloud-образа</div>
|
||||||
|
<div class="v-subtitle">Готовая система с пользователем и SSH за минуту — без установки с ISO</div>
|
||||||
|
</div>
|
||||||
|
<div class="top-meta">
|
||||||
|
<span>user: {{ user }}</span>
|
||||||
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="grid two">
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Параметры</h2>
|
||||||
|
<span class="pill">cloud-init</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/vm/create-cloud" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<label>
|
||||||
|
<span>Имя VM</span>
|
||||||
|
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="ubuntu-web" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Cloud-образ</span>
|
||||||
|
<select name="image_name" {% if images %}required{% endif %}>
|
||||||
|
{% for image in images %}
|
||||||
|
<option value="{{ image.name }}" {% if form.image_name == image.name %}selected{% endif %}>{{ image.label }} — {{ image.arch }} — {{ image.size }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
<span>RAM, MB</span>
|
||||||
|
<input type="number" name="memory" min="512" max="262144" step="512" value="{{ form.memory }}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>CPU</span>
|
||||||
|
<input type="number" name="vcpus" min="1" max="128" value="{{ form.vcpus }}" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Диск, GB</span>
|
||||||
|
<input type="number" name="disk_size" min="4" max="4096" value="{{ form.disk_size }}" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
<span>Пользователь в VM</span>
|
||||||
|
<input type="text" name="username" value="{{ form.username or 'admin' }}" placeholder="admin" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Пароль (необязательно, если есть SSH-ключ)</span>
|
||||||
|
<input type="password" name="password" value="" placeholder="пароль для входа">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Публичный SSH-ключ (необязательно)</span>
|
||||||
|
<input type="text" name="ssh_key" value="{{ form.ssh_key or '' }}" placeholder="ssh-ed25519 AAAA…">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Режим сети</span>
|
||||||
|
<select name="network_mode" required>
|
||||||
|
<option value="nat" {% if form.network_mode != 'bridge' %}selected{% endif %}>VPS NAT Router — virtuality-nat</option>
|
||||||
|
<option value="bridge" {% if form.network_mode == 'bridge' %}selected{% endif %}>Bridge — br0 / локальная сеть</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Bridge для режима Bridge</span>
|
||||||
|
<input type="text" name="bridge" value="{{ form.bridge }}" placeholder="br0">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="notice">
|
||||||
|
Пользователь и пароль/ключ будут настроены cloud-init при первом старте VM. Вход: <b>ssh пользователь@IP</b> или через консоль на странице VM.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="primary wide" {% if not images %}disabled{% endif %}>Создать VM из cloud-образа</button>
|
||||||
|
{% if not images %}
|
||||||
|
<div class="alert danger">Сначала скачай cloud-образ на странице <a href="/cloud-images">Cloud-образы</a>.</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Как это работает</h2>
|
||||||
|
<a class="link-pill" href="/cloud-images">Открыть cloud-образы</a>
|
||||||
|
</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<div class="big-action static">
|
||||||
|
<strong>1. Копия образа</strong>
|
||||||
|
<span>Скачанный cloud-образ копируется в новый qcow2-диск VM и расширяется до выбранного размера.</span>
|
||||||
|
</div>
|
||||||
|
<div class="big-action static">
|
||||||
|
<strong>2. Seed-диск cloud-init</strong>
|
||||||
|
<span>Из имени, пользователя и пароля/ключа собирается маленький seed-ISO, который VM читает при первом старте.</span>
|
||||||
|
</div>
|
||||||
|
<div class="big-action static">
|
||||||
|
<strong>3. Импорт и запуск</strong>
|
||||||
|
<span>virt-install импортирует диск и запускает VM. Прогресс — в разделе «Операции».</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre>qemu-img convert -O qcow2 cloud.qcow2 vm.qcow2
|
||||||
|
qemu-img resize vm.qcow2 20G
|
||||||
|
cloud-localds seed.iso user-data meta-data
|
||||||
|
virt-install --import --disk vm.qcow2 …</pre>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="/static/panel.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<span class="status {{ 'ok' if is_autostart else 'err' }}">autostart: {{ autostart_label }}</span>
|
<span class="status {{ 'ok' if is_autostart else 'err' }}">autostart: {{ autostart_label }}</span>
|
||||||
<a class="link-pill accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">Консоль ↗</a>
|
<a class="link-pill accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">Консоль ↗</a>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -75,6 +75,11 @@
|
|||||||
{% if iso_error %}<div class="alert danger">{{ iso_error }}</div>{% endif %}
|
{% if iso_error %}<div class="alert danger">{{ iso_error }}</div>{% endif %}
|
||||||
{% if boot_message %}<div class="alert success">{{ boot_message }}</div>{% endif %}
|
{% if boot_message %}<div class="alert success">{{ boot_message }}</div>{% endif %}
|
||||||
{% if boot_error %}<div class="alert danger">{{ boot_error }}</div>{% endif %}
|
{% if boot_error %}<div class="alert danger">{{ boot_error }}</div>{% endif %}
|
||||||
|
{% if clone_message %}<div class="alert success">{{ clone_message }}</div>{% endif %}
|
||||||
|
{% if clone_error %}<div class="alert danger">{{ clone_error }}</div>{% endif %}
|
||||||
|
{% if snap_message %}<div class="alert success">{{ snap_message }}</div>{% endif %}
|
||||||
|
{% if snap_error %}<div class="alert danger">{{ snap_error }}</div>{% endif %}
|
||||||
|
{% if is_template %}<div class="alert warn">Эта VM помечена как шаблон: запуск заблокирован. Клонируй её для создания новых машин.</div>{% endif %}
|
||||||
|
|
||||||
<section class="grid vm-four-grid">
|
<section class="grid vm-four-grid">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
@@ -88,10 +93,10 @@
|
|||||||
<div class="vm-actions compact-actions big-actions">
|
<div class="vm-actions compact-actions big-actions">
|
||||||
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Открыть консоль"><span>▣</span><em>Консоль</em></a>
|
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Открыть консоль"><span>▣</span><em>Консоль</em></a>
|
||||||
{% if is_running %}
|
{% if is_running %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/reboot"><button class="icon-action" title="Перезагрузить"><span>↻</span><em>Reboot</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/reboot"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Перезагрузить"><span>↻</span><em>Reboot</em></button></form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/shutdown"><button class="icon-action" title="Мягко выключить"><span>⏻</span><em>Shutdown</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/shutdown"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Мягко выключить"><span>⏻</span><em>Shutdown</em></button></form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/start"><button class="icon-action success" title="Запустить"><span>▶</span><em>Start</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/start"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action success" title="Запустить"><span>▶</span><em>Start</em></button></form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,11 +104,11 @@
|
|||||||
<div class="label">Автозапуск</div>
|
<div class="label">Автозапуск</div>
|
||||||
<div class="vm-actions compact-actions big-actions">
|
<div class="vm-actions compact-actions big-actions">
|
||||||
{% if is_autostart %}
|
{% if is_autostart %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="autostart-form">
|
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="autostart-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="autostart-indicator on" title="Автозапуск включён. Нажми, чтобы отключить"><span>●</span><strong>Включён</strong><small>Нажми для отключения</small></button>
|
<button class="autostart-indicator on" title="Автозапуск включён. Нажми, чтобы отключить"><span>●</span><strong>Включён</strong><small>Нажми для отключения</small></button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/autostart" class="autostart-form">
|
<form method="post" action="/vm/{{ vm.name }}/autostart" class="autostart-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="autostart-indicator off" title="Автозапуск выключен. Нажми, чтобы включить"><span>●</span><strong>Выключен</strong><small>Нажми для включения</small></button>
|
<button class="autostart-indicator off" title="Автозапуск выключен. Нажми, чтобы включить"><span>●</span><strong>Выключен</strong><small>Нажми для включения</small></button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -114,10 +119,10 @@
|
|||||||
<div class="label">Опасные действия</div>
|
<div class="label">Опасные действия</div>
|
||||||
<div class="vm-actions compact-actions big-actions">
|
<div class="vm-actions compact-actions big-actions">
|
||||||
{% if is_running %}
|
{% if is_running %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/destroy"><button class="icon-action danger" title="Принудительно выключить"><span>⚡</span><em>Power off</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/destroy"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Принудительно выключить"><span>⚡</span><em>Power off</em></button></form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not is_running %}
|
{% if not is_running %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><button class="icon-action danger" title="Удалить VM вместе с дисками"><span>🗑</span><em>Delete</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Удалить VM вместе с дисками"><span>🗑</span><em>Delete</em></button></form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="muted small-note">Удаление доступно после выключения VM.</span>
|
<span class="muted small-note">Удаление доступно после выключения VM.</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -131,7 +136,7 @@
|
|||||||
<h2>CPU / RAM / Архитектура</h2>
|
<h2>CPU / RAM / Архитектура</h2>
|
||||||
<span class="pill">resources</span>
|
<span class="pill">resources</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid">
|
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>
|
<label>
|
||||||
<span>CPU</span>
|
<span>CPU</span>
|
||||||
@@ -164,7 +169,7 @@
|
|||||||
<h2>Порядок загрузки</h2>
|
<h2>Порядок загрузки</h2>
|
||||||
<span class="pill">boot</span>
|
<span class="pill">boot</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form">
|
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="boot_order" id="boot-order-value" value="{{ current_boot_order|default('auto') }}">
|
<input type="hidden" name="boot_order" id="boot-order-value" value="{{ current_boot_order|default('auto') }}">
|
||||||
<div class="boot-order-list" id="boot-order-list" data-current="{{ current_boot_order|default('auto') }}">
|
<div class="boot-order-list" id="boot-order-list" data-current="{{ current_boot_order|default('auto') }}">
|
||||||
<div class="boot-order-item" draggable="true" data-device="cdrom"><span class="drag-handle">☰</span><div><b>ISO / CD-ROM</b><small>Установщик или rescue-образ</small></div></div>
|
<div class="boot-order-item" draggable="true" data-device="cdrom"><span class="drag-handle">☰</span><div><b>ISO / CD-ROM</b><small>Установщик или rescue-образ</small></div></div>
|
||||||
@@ -186,7 +191,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="muted small-note">ISO сейчас не подключён.</div>
|
<div class="muted small-note">ISO сейчас не подключён.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid">
|
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>ISO образ</span>
|
<span>ISO образ</span>
|
||||||
<select name="iso_path" required>
|
<select name="iso_path" required>
|
||||||
@@ -197,7 +202,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<button class="primary wide" type="submit" {% if not isos %}disabled{% endif %}>Смонтировать ISO</button>
|
<button class="primary wide" type="submit" {% if not isos %}disabled{% endif %}>Смонтировать ISO</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');">
|
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button type="submit" class="ghost wide" {% if not current_iso %}disabled{% endif %}>Отмонтировать ISO</button>
|
<button type="submit" class="ghost wide" {% if not current_iso %}disabled{% endif %}>Отмонтировать ISO</button>
|
||||||
</form>
|
</form>
|
||||||
{% if not isos %}
|
{% if not isos %}
|
||||||
@@ -244,6 +249,86 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Снапшоты</h2>
|
||||||
|
<span class="pill">{{ snapshots|length }} шт.</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/snapshot/create" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
<span>Имя снапшота</span>
|
||||||
|
<input type="text" name="snapshot_name" placeholder="before-update" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Комментарий (необязательно)</span>
|
||||||
|
<input type="text" name="description" placeholder="перед обновлением nginx">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="primary">Создать снапшот</button>
|
||||||
|
</form>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Имя</th><th>Создан</th><th>Состояние VM</th><th>Действия</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for snap in snapshots %}
|
||||||
|
<tr>
|
||||||
|
<td class="strong">{{ snap.name }}</td>
|
||||||
|
<td>{{ snap.created }}</td>
|
||||||
|
<td><span class="status {{ snap.state_css }}">{{ snap.state }}</span></td>
|
||||||
|
<td class="actions">
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/snapshot/revert" onsubmit="return confirm('Откатить VM {{ vm.name }} к снапшоту {{ snap.name }}? Текущее состояние диска будет потеряно.');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="snapshot_name" value="{{ snap.name }}">
|
||||||
|
<button class="ghost">Откатиться</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/snapshot/delete" onsubmit="return confirm('Удалить снапшот {{ snap.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="snapshot_name" value="{{ snap.name }}">
|
||||||
|
<button class="danger">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" class="muted">Снапшотов пока нет. Создай снапшот перед рискованными изменениями — к нему можно будет откатиться одной кнопкой.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted small-note">Снапшот работающей VM сохраняет и память — VM продолжит работу с того же места после отката. Снапшот выключенной VM сохраняет только диск.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid two">
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Клонирование</h2>
|
||||||
|
<span class="pill">virt-clone</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/clone" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<label>
|
||||||
|
<span>Имя новой VM</span>
|
||||||
|
<input type="text" name="new_name" placeholder="{{ vm.name }}-copy" required>
|
||||||
|
</label>
|
||||||
|
<button class="primary wide" {% if is_running %}disabled{% endif %}>Клонировать VM</button>
|
||||||
|
{% if is_running %}
|
||||||
|
<p class="muted small-note">Клонирование доступно только для выключенной VM — сначала выключи её.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted small-note">Диски копируются целиком, MAC-адрес нового клона генерируется автоматически. Прогресс — в «Операциях».</p>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Шаблон</h2>
|
||||||
|
<span class="pill">{{ 'template' if is_template else 'обычная VM' }}</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/template" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="enabled" value="{{ '0' if is_template else '1' }}">
|
||||||
|
<button class="{{ 'ghost' if is_template else 'primary' }} wide">{{ 'Снять флаг шаблона' if is_template else 'Пометить как шаблон' }}</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted small-note">Шаблон — эталонная VM для клонирования: запуск заблокирован, чтобы случайно не изменить эталонный диск. Подготовь VM, выключи её и пометь как шаблон.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>Консоль</h2>
|
<h2>Консоль</h2>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Быстрый доступ к libvirt через python-байндинги.
|
||||||
|
|
||||||
|
Одно постоянное соединение вместо десятков subprocess-вызовов virsh на
|
||||||
|
каждую загрузку страницы — критично для Raspberry Pi и слабых VPS.
|
||||||
|
Если libvirt-python не установлен или libvirtd недоступен, модуль честно
|
||||||
|
сообщает об этом через available(), и приложение работает через virsh.
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import libvirt # python3-libvirt / libvirt-python
|
||||||
|
# Отключаем печать ошибок libvirt в stderr — ошибки обрабатываются кодом.
|
||||||
|
libvirt.registerErrorHandler(lambda ctx, err: None, None)
|
||||||
|
except ImportError:
|
||||||
|
libvirt = None
|
||||||
|
|
||||||
|
_CONN_LOCK = threading.Lock()
|
||||||
|
_conn = None
|
||||||
|
|
||||||
|
# Коды состояний libvirt → строки в стиле virsh, которые уже ждёт UI.
|
||||||
|
_STATE_NAMES = {
|
||||||
|
0: "no state",
|
||||||
|
1: "running",
|
||||||
|
2: "blocked",
|
||||||
|
3: "paused",
|
||||||
|
4: "in shutdown",
|
||||||
|
5: "shut off",
|
||||||
|
6: "crashed",
|
||||||
|
7: "pmsuspended",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _connection():
|
||||||
|
global _conn
|
||||||
|
if libvirt is None:
|
||||||
|
return None
|
||||||
|
with _CONN_LOCK:
|
||||||
|
if _conn is not None:
|
||||||
|
try:
|
||||||
|
if _conn.isAlive():
|
||||||
|
return _conn
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
_conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_conn = None
|
||||||
|
try:
|
||||||
|
_conn = libvirt.open("qemu:///system")
|
||||||
|
except Exception:
|
||||||
|
_conn = None
|
||||||
|
return _conn
|
||||||
|
|
||||||
|
|
||||||
|
def available() -> bool:
|
||||||
|
return _connection() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def list_domains() -> list[dict[str, Any]] | None:
|
||||||
|
"""Список всех доменов с состоянием и автозапуском. None — backend недоступен."""
|
||||||
|
conn = _connection()
|
||||||
|
if conn is None:
|
||||||
|
return None
|
||||||
|
rows = []
|
||||||
|
try:
|
||||||
|
for dom in conn.listAllDomains(0):
|
||||||
|
try:
|
||||||
|
state_code = dom.state()[0]
|
||||||
|
dom_id = dom.ID()
|
||||||
|
rows.append({
|
||||||
|
"id": str(dom_id) if dom_id > 0 else "-",
|
||||||
|
"name": dom.name(),
|
||||||
|
"state": _STATE_NAMES.get(state_code, "unknown"),
|
||||||
|
"autostart_enabled": bool(dom.autostart()),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
rows.sort(key=lambda row: row["name"])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def domain_stats() -> dict[str, dict[str, int]] | None:
|
||||||
|
"""cpu_time (нс) и память (KiB) работающих доменов для сбора метрик."""
|
||||||
|
conn = _connection()
|
||||||
|
if conn is None:
|
||||||
|
return None
|
||||||
|
stats: dict[str, dict[str, int]] = {}
|
||||||
|
try:
|
||||||
|
for dom in conn.listAllDomains(libvirt.VIR_CONNECT_LIST_DOMAINS_ACTIVE):
|
||||||
|
try:
|
||||||
|
info = dom.info() # [state, maxMem, memory, nrVirtCpu, cpuTime]
|
||||||
|
stats[dom.name()] = {"cpu_time_ns": int(info[4]), "memory_kb": int(info[2]), "max_memory_kb": int(info[1]), "vcpus": int(info[3])}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def node_memory_mb() -> dict[str, int] | None:
|
||||||
|
"""Память хоста по данным libvirt (fallback для метрик)."""
|
||||||
|
conn = _connection()
|
||||||
|
if conn is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
info = conn.getInfo() # [model, memory_mb, cpus, ...]
|
||||||
|
return {"total_mb": int(info[1]), "cpus": int(info[2])}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
Ссылка в новой задаче
Block a user