Patch-скрипты были слиты в исходники web-панели
Все 21 патч из install_web_panel.sh применены к web/app.py, web/network_core.py и web/static/app.css, механизм текстовых патчей удалён из установщиков, а сами patch_*.py (включая 7 неиспользуемых устаревших) удалены из scripts/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKUoZwHMCHnytfswFVRUHw
Этот коммит содержится в:
@@ -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"
|
||||||
|
|||||||
@@ -75,16 +75,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; }
|
||||||
@@ -167,113 +157,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"
|
||||||
|
|||||||
@@ -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}')
|
|
||||||
+1286
-27
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
+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'],
|
||||||
|
|||||||
@@ -295,3 +295,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; }
|
||||||
|
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user