diff --git a/scripts/install_disk_images_support.sh b/scripts/install_disk_images_support.sh index 96e9f1a..a88578d 100644 --- a/scripts/install_disk_images_support.sh +++ b/scripts/install_disk_images_support.sh @@ -1,20 +1,11 @@ #!/usr/bin/env bash 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" mkdir -p "$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 echo "Disk image support installed. Open: /disk-images" diff --git a/scripts/install_web_panel.sh b/scripts/install_web_panel.sh index 4675ab8..459dbfa 100644 --- a/scripts/install_web_panel.sh +++ b/scripts/install_web_panel.sh @@ -75,16 +75,6 @@ run_logged() { 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"; } 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; } @@ -167,113 +157,6 @@ fi step "Копируем web-панель в /opt/virtuality" run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality 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" if [[ -f "$PROFILE_FILE" ]]; then ok "Профиль уже сохранён: $PROFILE_FILE" diff --git a/scripts/patch_arm64_emulation_xml.py b/scripts/patch_arm64_emulation_xml.py deleted file mode 100644 index 5fbb381..0000000 --- a/scripts/patch_arm64_emulation_xml.py +++ /dev/null @@ -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""" - - - - """ - else: - interface_xml = f""" - - - - """ - return f""" - {xml_escape(name)} - {int(memory)} - {int(memory)} - {int(vcpus)} - - hvm - - - cortex-a57 - - - - - - destroy - restart - restart - - /usr/bin/qemu-system-aarch64 - - - - - {interface_xml} - - - - - - - - - -""" - - -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}') diff --git a/scripts/patch_dhcp_leases_empty.py b/scripts/patch_dhcp_leases_empty.py deleted file mode 100644 index 6f97318..0000000 --- a/scripts/patch_dhcp_leases_empty.py +++ /dev/null @@ -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 = 'IDNameStateActions' -header_new = 'IDNameIPStateActions' -name_cell = '''{{ vm.name }} - {{ vm.name }} - {{ vm.ip|default("—") }} - IP' 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 = ''' -
-
-

Ручные IP VM

- override -
-
Если VM в bridge/static-сети и IP не виден через DHCP/ARP, укажи адрес вручную. Этот IP будет первым источником для таблицы и проброса портов.
- - - - {% for vm in vms %} - - - - - - - {% else %} - - {% endfor %} - -
VMТекущий IPРучной IPДействие
{{ vm.name }}{{ vm.ip|default("—") }} -
- - -
VM пока нет
-
-''' - if 'Ручные IP VM' not in network_html: - marker = '
\n
\n

Диагностика публичного доступа

' - 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) diff --git a/scripts/patch_disk_archives.py b/scripts/patch_disk_archives.py deleted file mode 100644 index 93938a7..0000000 --- a/scripts/patch_disk_archives.py +++ /dev/null @@ -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}') diff --git a/scripts/patch_disk_convert_progress.py b/scripts/patch_disk_convert_progress.py deleted file mode 100644 index 441810a..0000000 --- a/scripts/patch_disk_convert_progress.py +++ /dev/null @@ -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) diff --git a/scripts/patch_disk_images.py b/scripts/patch_disk_images.py deleted file mode 100644 index 03688f3..0000000 --- a/scripts/patch_disk_images.py +++ /dev/null @@ -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}') diff --git a/scripts/patch_existing_vm_boot_order.py b/scripts/patch_existing_vm_boot_order.py deleted file mode 100644 index e9aae63..0000000 --- a/scripts/patch_existing_vm_boot_order.py +++ /dev/null @@ -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 %} -
{{ boot_message }}
- {% endif %} - {% if boot_error %} -
{{ boot_error }}
- {% endif %} - -
-
-

Порядок загрузки

- boot order -
-
- - -
-

Настройка меняет XML-конфигурацию VM через virsh define. Если машина сейчас запущена, новый порядок загрузки сработает после перезапуска.

-
-''' - markers = ['\n\n
', '\n\n
', '\n\n
'] - 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}') diff --git a/scripts/patch_existing_vm_iso_mount.py b/scripts/patch_existing_vm_iso_mount.py deleted file mode 100644 index b8eef27..0000000 --- a/scripts/patch_existing_vm_iso_mount.py +++ /dev/null @@ -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''' -
-
-

ISO-привод

- cdrom -
- {% if current_iso %} -
Сейчас подключен ISO: {{ current_iso }}
- {% else %} -
ISO сейчас не подключён.
- {% endif %} -
- - -
-
- -
- {% if not isos %} -
ISO-образов пока нет. Сначала загрузи .iso в разделе ISO.
- {% endif %} -

Для запущенной VM ISO подключается live и сохраняется в конфигурации. Для выключенной VM ISO будет доступен при следующем старте.

-
-''' - -boot_card = r''' -
-
-

Порядок загрузки

- drag boot -
-
- -
-
ISO / CD-ROMУстановщик или rescue-образ
-
ДискОсновной qcow2/raw диск VM
-
Сеть / PXEЗагрузка по сети
-
- -
-

Перетащи нужный источник выше. Для загрузки с ISO поставь ISO / CD-ROM первым. Изменение сработает после перезапуска VM.

- -
-''' - -if template_path.exists(): - tpl = template_path.read_text() - original = tpl - settings_grid = '\n\n
\n' + iso_card + boot_card + '
\n' - - if 'action="/vm/{{ vm.name }}/iso/mount"' not in tpl and 'vm-boot-iso-grid' not in tpl: - markers = ['\n\n
', '\n\n
', '\n\n
'] - 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}') diff --git a/scripts/patch_existing_vm_resources.py b/scripts/patch_existing_vm_resources.py deleted file mode 100644 index 8f3ee26..0000000 --- a/scripts/patch_existing_vm_resources.py +++ /dev/null @@ -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}') diff --git a/scripts/patch_live_status.py b/scripts/patch_live_status.py deleted file mode 100644 index bc99ce9..0000000 --- a/scripts/patch_live_status.py +++ /dev/null @@ -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}') diff --git a/scripts/patch_logs_center.py b/scripts/patch_logs_center.py deleted file mode 100644 index 2494186..0000000 --- a/scripts/patch_logs_center.py +++ /dev/null @@ -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 %} - -''' - -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 '' not in html: - html = html.replace('', ' \n', 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}') diff --git a/scripts/patch_network_bridge_forwards.py b/scripts/patch_network_bridge_forwards.py deleted file mode 100644 index 93cf5bc..0000000 --- a/scripts/patch_network_bridge_forwards.py +++ /dev/null @@ -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 ''} 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 ''} and {protocol} port {int(guest_port)}'\",", -) - -core_path.write_text(text) -print('network bridge forward patch applied:') -for item in changed: - print(f'- {item}') diff --git a/scripts/patch_network_diagnostics.py b/scripts/patch_network_diagnostics.py deleted file mode 100644 index cc1fd8f..0000000 --- a/scripts/patch_network_diagnostics.py +++ /dev/null @@ -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}') diff --git a/scripts/patch_network_manual_ip_field.py b/scripts/patch_network_manual_ip_field.py deleted file mode 100644 index 1ba459d..0000000 --- a/scripts/patch_network_manual_ip_field.py +++ /dev/null @@ -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 = '' -new = '''''' - -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') diff --git a/scripts/patch_network_nat_errors.py b/scripts/patch_network_nat_errors.py deleted file mode 100644 index e620703..0000000 --- a/scripts/patch_network_nat_errors.py +++ /dev/null @@ -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}') diff --git a/scripts/patch_network_ranges.py b/scripts/patch_network_ranges.py deleted file mode 100644 index 41d42fb..0000000 --- a/scripts/patch_network_ranges.py +++ /dev/null @@ -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}') diff --git a/scripts/patch_remove_legacy_boot_order_card.py b/scripts/patch_remove_legacy_boot_order_card.py deleted file mode 100644 index e5c67a9..0000000 --- a/scripts/patch_remove_legacy_boot_order_card.py +++ /dev/null @@ -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*
\s*\n' - r'\s*
\s*\n' - r'\s*

Порядок загрузки

\s*\n' - r'\s*boot order\s*\n' - r'.*?' - r'\s*
\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*
\s*\n' - r'\s*
\s*\n' - r'\s*

Порядок загрузки

.*?' - r'