Patch-скрипты были слиты в исходники web-панели

Все 21 патч из install_web_panel.sh применены к web/app.py,
web/network_core.py и web/static/app.css, механизм текстовых патчей
удалён из установщиков, а сами patch_*.py (включая 7 неиспользуемых
устаревших) удалены из scripts/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKUoZwHMCHnytfswFVRUHw
Этот коммит содержится в:
Claude
2026-08-15 19:01:43 +00:00
родитель 423c00b96a
Коммит 77a263ec86
33 изменённых файлов: 1379 добавлений и 4242 удалений
-9
Просмотреть файл
@@ -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"
-117
Просмотреть файл
@@ -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"
-210
Просмотреть файл
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def arm64_emulation_xml(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str:
if network_mode == "nat":
interface_xml = f"""
<interface type='network'>
<source network='{xml_escape(network_core.NETWORK_NAME)}'/>
<model type='virtio'/>
</interface>"""
else:
interface_xml = f"""
<interface type='bridge'>
<source bridge='{xml_escape(bridge)}'/>
<model type='virtio'/>
</interface>"""
return f"""<domain type='qemu'>
<name>{xml_escape(name)}</name>
<memory unit='MiB'>{int(memory)}</memory>
<currentMemory unit='MiB'>{int(memory)}</currentMemory>
<vcpu placement='static'>{int(vcpus)}</vcpu>
<os>
<type arch='aarch64' machine='virt'>hvm</type>
</os>
<cpu mode='custom' match='exact'>
<model fallback='allow'>cortex-a57</model>
</cpu>
<features>
<gic version='3'/>
</features>
<clock offset='utc'/>
<on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot>
<on_crash>restart</on_crash>
<devices>
<emulator>/usr/bin/qemu-system-aarch64</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='{xml_escape(str(disk_path))}'/>
<target dev='vda' bus='virtio'/>
</disk>{interface_xml}
<graphics type='vnc' port='-1' autoport='yes' listen='0.0.0.0'>
<listen type='address' address='0.0.0.0'/>
</graphics>
<video>
<model type='virtio'/>
</video>
<console type='pty'>
<target type='serial' port='0'/>
</console>
</devices>
</domain>
"""
def make_arm64_emulation_script(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str:
xml_path = Path('/tmp') / f"virtuality-{name}-arm64.xml"
xml_path.write_text(arm64_emulation_xml(name, memory, vcpus, disk_path, network_mode, bridge))
return f"virsh define {xml_path} && virsh start {name}"
'''
if 'def arm64_emulation_xml(' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker not in text:
raise SystemExit('valid_vm_name marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('ARM64 emulation XML helpers added')
else:
if 'def arm64_emulation_memory_error(' not in text:
marker = '\n\ndef xml_escape(value: str) -> str:'
if marker not in text:
raise SystemExit('xml_escape marker not found')
memory_helpers = r'''
def available_memory_mib() -> int:
try:
for line in Path('/proc/meminfo').read_text().splitlines():
if line.startswith('MemAvailable:'):
return int(line.split()[1]) // 1024
except Exception:
return 0
return 0
def arm64_emulation_memory_error(memory: int) -> str | None:
available = available_memory_mib()
requested = int(memory)
reserve = 768
if available <= 0:
return None
if requested + reserve <= available:
return None
recommended = max(512, available - reserve)
return (
f"Недостаточно RAM для ARM64-эмуляции: запрошено {requested} MB, "
f"доступно около {available} MB, нужен запас минимум {reserve} MB. "
f"Уменьши RAM VM примерно до {recommended} MB или освободи память на хосте."
)
'''
text = text.replace(marker, memory_helpers + marker, 1)
changed.append('ARM64 memory guard helpers added')
else:
changed.append('ARM64 memory guard helpers already present')
changed.append('ARM64 emulation XML helpers already present')
old_disk_cmd = ''' if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
new_disk_cmd = ''' if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
if is_arm64_guest_on_x86(resolved_guest_arch, profile):
memory_error = arm64_emulation_memory_error(memory)
if memory_error:
return vm_form_context(request, error=memory_error, form=form, status_code=400)
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
if is_arm64_guest_on_x86(resolved_guest_arch, profile):
return vm_form_context(request, error="ARM64 ISO на x86-хосте пока не поддерживается. Загрузи готовый ARM64 .img/.qcow2 в разделе «Диски» и создай VM из готового диска.", form=form, status_code=400)
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
old_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile):
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
'''
new_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile):
memory_error = arm64_emulation_memory_error(memory)
if memory_error:
return vm_form_context(request, error=memory_error, form=form, status_code=400)
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
'''
if old_disk_cmd in text:
text = text.replace(old_disk_cmd, new_disk_cmd, 1)
changed.append('ARM64 disk image on x86 uses direct libvirt XML with memory guard')
elif old_arm64_xml_cmd in text:
text = text.replace(old_arm64_xml_cmd, new_arm64_xml_cmd, 1)
changed.append('ARM64 memory guard added to existing XML path')
elif 'memory_error = arm64_emulation_memory_error(memory)' in text:
changed.append('ARM64 memory guard already present')
elif 'make_arm64_emulation_script' in text:
changed.append('ARM64 disk image XML path already present')
else:
raise SystemExit('disk image create command marker not found')
app_path.write_text(text)
print('arm64 emulation xml patch applied:')
for item in changed:
print(f'- {item}')
-271
Просмотреть файл
@@ -1,271 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
changed: list[str] = []
warnings: list[str] = []
if not app_path.exists():
print(f'WARN: app.py not found: {app_path}')
raise SystemExit(0)
templates_dir = app_path.parent / 'templates'
dashboard_template_path = templates_dir / 'dashboard.html'
network_template_path = templates_dir / 'network.html'
if not dashboard_template_path.exists():
print(f'WARN: dashboard.html not found: {dashboard_template_path}')
raise SystemExit(0)
text = app_path.read_text(encoding='utf-8')
text, removed_helpers = re.subn(
r"\n\ndef parse_dhcp_leases_output\(output: str\).*?\n\ndef parse_virsh_list\(\) -> list\[dict\[str, str\]\]:",
"\n\ndef parse_virsh_list() -> list[dict[str, str]]:",
text,
count=1,
flags=re.S,
)
if removed_helpers:
changed.append('old VM IP helper block was removed')
new_parse_virsh_list = '''def parse_virsh_list() -> list[dict[str, str]]:
def clean_ip(ip: str) -> str:
ip = (ip or "").split("/")[0].strip()
if not ip or ip.startswith("127.") or ip.startswith("169.254.") or ip == "0.0.0.0":
return ""
return ip
def manual_ip_map() -> dict[str, str]:
path = Path("/var/lib/virtuality/network/vm_ips.json")
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return {str(k): clean_ip(str(v)) for k, v in data.items() if clean_ip(str(v))}
except Exception:
pass
return {}
def vm_macs(name: str) -> list[str]:
try:
result = run_cmd(["virsh", "domiflist", name], timeout=8)
if not result.get("ok"):
return []
return [mac.lower() for mac in re.findall(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", result.get("stdout", ""))]
except Exception:
return []
def ip_from_domifaddr(name: str) -> str:
try:
result = run_cmd(["virsh", "domifaddr", name], timeout=8)
if result.get("ok"):
for ip in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})/\\d+", result.get("stdout", "")):
ip = clean_ip(ip)
if ip:
return ip
except Exception:
pass
return ""
def ip_from_network_core(name: str) -> str:
try:
resolved = network_core.resolve_vm_ip(name)
if resolved:
return clean_ip(str(resolved))
except Exception:
pass
return ""
def ip_from_dnsmasq_leases(macs: list[str]) -> str:
if not macs:
return ""
try:
for lease_file in Path("/var/lib/libvirt/dnsmasq").glob("*.leases"):
for line in lease_file.read_text(errors="ignore").splitlines():
low = line.lower()
if not any(mac in low for mac in macs):
continue
parts = line.split()
if len(parts) >= 3:
ip = clean_ip(parts[2])
if ip:
return ip
except Exception:
pass
return ""
def ip_from_neighbor_tables(macs: list[str]) -> str:
if not macs:
return ""
commands = [["ip", "neigh", "show"], ["ip", "neigh", "show", "dev", "virbr100"], ["ip", "neigh", "show", "dev", "br0"], ["arp", "-an"]]
for cmd in commands:
try:
result = run_cmd(cmd, timeout=8)
if not result.get("ok"):
continue
for line in result.get("stdout", "").splitlines():
low = line.lower()
if not any(mac in low for mac in macs):
continue
for value in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})\\b", line):
ip = clean_ip(value)
if ip:
return ip
except Exception:
pass
return ""
manual_ips = manual_ip_map()
def resolve_ip(name: str) -> str:
if not name:
return ""
if manual_ips.get(name):
return manual_ips[name]
macs = vm_macs(name)
for resolver in (lambda: ip_from_domifaddr(name), lambda: ip_from_network_core(name), lambda: ip_from_dnsmasq_leases(macs), lambda: ip_from_neighbor_tables(macs)):
try:
ip = resolver()
if ip:
return ip
except Exception:
pass
return ""
result = run_cmd(["virsh", "list", "--all"])
rows = []
if not result["ok"]:
return rows
for line in result["stdout"].splitlines()[2:]:
parts = line.strip().split(None, 2)
if len(parts) == 3:
rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})
elif len(parts) == 2:
rows.append({"id": "-", "name": parts[0], "state": parts[1]})
for row in rows:
row["ip"] = resolve_ip(row.get("name", ""))
row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
return rows
'''
pattern = r"def parse_virsh_list\(\) -> list\[dict\[str, str\]\]:.*?\n\ndef parse_pool_list\(\) -> list\[dict\[str, str\]\]:"
replacement = new_parse_virsh_list + "\n\ndef parse_pool_list() -> list[dict[str, str]]:"
text, replaced = re.subn(pattern, lambda _match: replacement, text, count=1, flags=re.S)
if replaced:
changed.append('parse_virsh_list was replaced with manual-first VM IP resolver')
else:
warnings.append('parse_virsh_list block not found, app.py IP injection skipped')
manual_route = '''
@app.post("/network/vm-ip/save")
def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
if not valid_vm_name(vm_name):
return RedirectResponse(url="/network", status_code=303)
manual_ip = (manual_ip or "").strip()
if manual_ip and not re.fullmatch(r"(25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}", manual_ip):
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400)
path = Path("/var/lib/virtuality/network/vm_ips.json")
path.parent.mkdir(parents=True, exist_ok=True)
try:
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
if manual_ip:
data[vm_name] = manual_ip
else:
data.pop(vm_name, None)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
return RedirectResponse(url="/network", status_code=303)
'''
if '/network/vm-ip/save' not in text:
marker = '\n\n@app.post("/network/nat/setup")'
if marker in text:
text = text.replace(marker, manual_route + marker, 1)
changed.append('manual VM IP route was added')
else:
warnings.append('network NAT route marker not found, manual IP route skipped')
else:
changed.append('manual VM IP route was already present')
app_path.write_text(text, encoding='utf-8')
dashboard_html = dashboard_template_path.read_text(encoding='utf-8')
header_old = '<tr><th>ID</th><th>Name</th><th>State</th><th>Actions</th></tr>'
header_new = '<tr><th>ID</th><th>Name</th><th>IP</th><th>State</th><th>Actions</th></tr>'
name_cell = '''<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
<td><span'''
name_ip_cell = '''<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
<td class="strong">{{ vm.ip|default("") }}</td>
<td><span'''
if '<th>IP</th>' not in dashboard_html:
dashboard_html = dashboard_html.replace(header_old, header_new).replace(name_cell, name_ip_cell).replace('colspan="4" class="muted">Виртуальных машин пока нет', 'colspan="5" class="muted">Виртуальных машин пока нет')
changed.append('Dashboard VM IP column was added')
elif 'vm.ip' not in dashboard_html:
dashboard_html = dashboard_html.replace(name_cell, name_ip_cell)
changed.append('Dashboard VM IP cell was added')
else:
changed.append('Dashboard VM IP column was already present')
dashboard_template_path.write_text(dashboard_html, encoding='utf-8')
if network_template_path.exists():
network_html = network_template_path.read_text(encoding='utf-8')
manual_card = '''
<article class="card">
<div class="card-head">
<h2>Ручные IP VM</h2>
<span class="pill">override</span>
</div>
<div class="notice">Если VM в bridge/static-сети и IP не виден через DHCP/ARP, укажи адрес вручную. Этот IP будет первым источником для таблицы и проброса портов.</div>
<table class="top-space">
<thead><tr><th>VM</th><th>Текущий IP</th><th>Ручной IP</th><th>Действие</th></tr></thead>
<tbody>
{% for vm in vms %}
<tr>
<td class="strong">{{ vm.name }}</td>
<td>{{ vm.ip|default("") }}</td>
<td>
<form method="post" action="/network/vm-ip/save" class="inline-form">
<input type="hidden" name="vm_name" value="{{ vm.name }}">
<input type="text" name="manual_ip" value="{{ vm.manual_ip|default("") }}" placeholder="например 10.0.0.50" pattern="(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])){3}">
</td>
<td><button>Сохранить</button></form></td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">VM пока нет</td></tr>
{% endfor %}
</tbody>
</table>
</article>
'''
if 'Ручные IP VM' not in network_html:
marker = ' <article class="card">\n <div class="card-head">\n <h2>Диагностика публичного доступа</h2>'
if marker in network_html:
network_html = network_html.replace(marker, manual_card + '\n' + marker, 1)
changed.append('manual VM IP card was added to network page')
else:
warnings.append('network diagnostics card marker not found, manual IP card skipped')
else:
changed.append('manual VM IP card was already present')
network_template_path.write_text(network_html, encoding='utf-8')
else:
warnings.append('network.html not found, manual IP card skipped')
print('DHCP leases empty-state patch completed:')
for item in changed:
print(f'- {item}')
for item in warnings:
print(f'WARN: {item}')
raise SystemExit(0)
-316
Просмотреть файл
@@ -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}')
-171
Просмотреть файл
@@ -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)
-298
Просмотреть файл
@@ -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}')
-224
Просмотреть файл
@@ -1,224 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
warnings = []
text = app_path.read_text()
if 'import tempfile' not in text:
text = text.replace('import subprocess\n', 'import subprocess\nimport tempfile\n', 1)
changed.append('tempfile import added')
else:
changed.append('tempfile import already present')
if 'import xml.etree.ElementTree as ET' not in text:
text = text.replace('from typing import Any\n', 'from typing import Any\nimport xml.etree.ElementTree as ET\n', 1)
changed.append('ElementTree import added')
else:
changed.append('ElementTree import already present')
helpers = r'''
def vm_boot_order_label(value: str) -> str:
labels = {item["value"]: item["label"] for item in vm_boot_order_options()}
return labels.get(value or "auto", "Auto — по источнику VM")
def boot_order_to_devs(value: str) -> list[str]:
value = normalize_boot_order(value, "disk_image")
mapping = {
"disk": ["hd"],
"cdrom_disk": ["cdrom", "hd"],
"disk_cdrom": ["hd", "cdrom"],
"network_disk": ["network", "hd"],
}
return mapping.get(value, ["hd"])
def boot_devs_to_order(devs: list[str]) -> str:
clean = [item for item in devs if item in ("hd", "cdrom", "network")]
if clean[:2] == ["cdrom", "hd"]:
return "cdrom_disk"
if clean[:2] == ["hd", "cdrom"]:
return "disk_cdrom"
if clean[:2] == ["network", "hd"]:
return "network_disk"
if clean[:1] == ["hd"]:
return "disk"
return "auto"
def current_vm_boot_order(name: str) -> str:
result = run_cmd(["virsh", "dumpxml", name], timeout=12)
if not result.get("ok"):
return "auto"
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception:
return "auto"
os_node = root.find("os")
if os_node is None:
return "auto"
devs = []
for boot in os_node.findall("boot"):
dev = boot.attrib.get("dev", "").strip()
if dev:
devs.append(dev)
return boot_devs_to_order(devs)
def apply_vm_boot_order(name: str, boot_order: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
if boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):
return False, "Некорректный порядок загрузки VM."
selected = normalize_boot_order(boot_order, "disk_image")
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
if not result.get("ok"):
return False, result.get("stderr") or "Не удалось получить XML VM."
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception as exc:
return False, f"Не удалось разобрать XML VM: {exc}"
os_node = root.find("os")
if os_node is None:
os_node = ET.SubElement(root, "os")
for boot in list(os_node.findall("boot")):
os_node.remove(boot)
insert_at = 0
for idx, child in enumerate(list(os_node)):
if child.tag in ("type", "loader", "nvram", "firmware", "smbios", "bootmenu"):
insert_at = idx + 1
for dev in reversed(boot_order_to_devs(selected)):
boot_node = ET.Element("boot", {"dev": dev})
os_node.insert(insert_at, boot_node)
xml_text = ET.tostring(root, encoding="unicode")
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
handle.write(xml_text)
tmp_name = handle.name
try:
define = run_cmd(["virsh", "define", tmp_name], timeout=30)
finally:
Path(tmp_name).unlink(missing_ok=True)
if not define.get("ok"):
return False, define.get("stderr") or "virsh define завершился ошибкой."
return True, f"Порядок загрузки применён: {vm_boot_order_label(selected)}. Если VM запущена, изменение сработает после перезапуска."
'''
if 'def apply_vm_boot_order(name: str, boot_order: str)' not in text:
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('existing VM boot order helpers added')
else:
warnings.append('vm_details marker not found, helpers skipped')
else:
changed.append('existing VM boot order helpers already present')
old = '"host_ip": system_summary()["ip"]})'
new = '"host_ip": system_summary()["ip"], "boot_options": vm_boot_order_options(), "current_boot_order": current_vm_boot_order(name), "boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})'
if '"current_boot_order": current_vm_boot_order(name)' not in text:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm detail context gets boot order')
else:
warnings.append('vm_detail context marker not found, skipped')
else:
changed.append('vm detail context already has boot order')
route = r'''
@app.post("/vm/{name}/boot-order")
def vm_boot_order_apply(request: Request, name: str, boot_order: str = Form("auto")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = apply_vm_boot_order(name, boot_order)
if ok:
return RedirectResponse(url=f"/vm/{name}?boot_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?boot_error={message}", status_code=303)
'''
if '@app.post("/vm/{name}/boot-order")' not in text:
marker = '\n\n@app.post("/vm/{name}/{action}")'
if marker in text:
text = text.replace(marker, route + marker, 1)
changed.append('existing VM boot order route added')
else:
warnings.append('generic vm action marker not found, route skipped')
else:
changed.append('existing VM boot order route already present')
app_path.write_text(text)
if template_path.exists():
tpl = template_path.read_text()
original = tpl
if 'action="/vm/{{ vm.name }}/boot-order"' not in tpl:
block = r'''
{% if boot_message %}
<div class="alert success">{{ boot_message }}</div>
{% endif %}
{% if boot_error %}
<div class="alert danger">{{ boot_error }}</div>
{% endif %}
<section class="card">
<div class="card-head">
<h2>Порядок загрузки</h2>
<span class="pill">boot order</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid">
<label>
<span>Порядок загрузки VM</span>
<select name="boot_order" required>
{% for boot in boot_options %}
<option value="{{ boot.value }}" {% if current_boot_order == boot.value %}selected{% endif %}>{{ boot.label }}</option>
{% endfor %}
</select>
</label>
<button class="primary wide" type="submit">Применить порядок загрузки</button>
</form>
<p class="muted small-note">Настройка меняет XML-конфигурацию VM через virsh define. Если машина сейчас запущена, новый порядок загрузки сработает после перезапуска.</p>
</section>
'''
markers = ['\n\n <section class="grid two">', '\n\n <section class="grid two">', '\n\n <section class="card">']
inserted = False
for marker in markers:
if marker in tpl:
tpl = tpl.replace(marker, block + marker, 1)
inserted = True
changed.append('boot order card added to vm_detail.html')
break
if not inserted:
warnings.append('vm detail insertion marker not found, boot order card skipped')
else:
changed.append('boot order card already present in vm_detail.html')
if tpl != original:
template_path.write_text(tpl)
else:
warnings.append(f'vm_detail.html not found: {template_path}')
print('existing VM boot order patch applied:')
for item in changed:
print(f'- {item}')
if warnings:
print('Warnings:')
for item in warnings:
print(f'- {item}')
-280
Просмотреть файл
@@ -1,280 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
text = app_path.read_text()
helpers = r'''
def vm_state(name: str) -> str:
result = run_cmd(["virsh", "domstate", name], timeout=8)
return (result.get("stdout") or "unknown").strip().lower()
def current_vm_iso(name: str) -> str:
result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)
if not result.get("ok"):
return ""
for line in (result.get("stdout") or "").splitlines():
if ".iso" not in line.lower():
continue
parts = line.split()
if parts:
return parts[-1]
return ""
def detach_vm_iso(name: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
xml = run_cmd(["virsh", "dumpxml", name], timeout=12)
cdrom_targets: list[str] = []
if xml.get("ok"):
try:
root = ET.fromstring(xml.get("stdout") or "")
devices = root.find("devices")
if devices is not None:
for disk in devices.findall("disk"):
if disk.attrib.get("device") != "cdrom":
continue
target = disk.find("target")
dev = target.attrib.get("dev") if target is not None else ""
if dev:
cdrom_targets.append(dev)
except Exception:
pass
if not cdrom_targets:
cdrom_targets = ["sda", "hda", "sdb", "hdc"]
running = vm_state(name) == "running"
errors = []
changed_any = False
for target in cdrom_targets:
commands = []
if running:
commands.append(["virsh", "detach-disk", name, target, "--live"])
commands.append(["virsh", "detach-disk", name, target, "--config"])
for cmd in commands:
result = run_cmd(cmd, timeout=30)
if result.get("ok"):
changed_any = True
elif result.get("stderr"):
errors.append(result.get("stderr"))
if changed_any:
return True, "ISO был отмонтирован."
return False, errors[-1] if errors else "Подключенный ISO не найден."
def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
iso = Path(iso_path or "").resolve()
try:
iso_root = ISO_DIR.resolve()
except Exception:
iso_root = Path("/var/lib/virtuality/iso")
if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
return False, "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
detach_vm_iso(name)
running = vm_state(name) == "running"
base = ["virsh", "attach-disk", name, str(iso), "sda", "--type", "cdrom", "--mode", "readonly"]
if running:
live = run_cmd(base + ["--live"], timeout=30)
if not live.get("ok"):
return False, live.get("stderr") or "Не удалось подключить ISO к запущенной VM."
config = run_cmd(base + ["--config"], timeout=30)
if not config.get("ok"):
return False, config.get("stderr") or "Не удалось сохранить ISO в конфигурации VM."
return True, "ISO был смонтирован в VM. Если гостевая ОС его не увидела сразу, перезагрузи VM или обнови устройства внутри гостевой ОС."
'''
if 'def mount_vm_iso(name: str, iso_path: str)' not in text:
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('existing VM ISO mount helpers added')
else:
changed.append('ISO helpers skipped: vm_details marker not found')
else:
changed.append('existing VM ISO mount helpers already present')
if '"current_iso": current_vm_iso(name)' not in text:
replacements = [
(
'"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})',
'"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
(
'"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})',
'"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
(
'"host_ip": system_summary()["ip"]})',
'"host_ip": system_summary()["ip"], "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
]
for old, new in replacements:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm detail context gets ISO mount data')
break
else:
changed.append('ISO context skipped: vm detail context marker not found')
else:
changed.append('vm detail context already has ISO mount data')
routes = r'''
@app.post("/vm/{name}/iso/mount")
def vm_iso_mount_apply(request: Request, name: str, iso_path: str = Form(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = mount_vm_iso(name, iso_path)
if ok:
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
@app.post("/vm/{name}/iso/unmount")
def vm_iso_unmount_apply(request: Request, name: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = detach_vm_iso(name)
if ok:
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
'''
if '@app.post("/vm/{name}/iso/mount")' not in text:
markers = ['\n\n@app.post("/vm/{name}/{action}")', '\n\n@app.get("/vm/create"', '\n\n@app.get("/operations"']
for marker in markers:
if marker in text:
text = text.replace(marker, routes + marker, 1)
changed.append('existing VM ISO mount routes added')
break
else:
changed.append('ISO routes skipped: safe route marker not found')
else:
changed.append('existing VM ISO mount routes already present')
app_path.write_text(text)
iso_card = r'''
<article class="card">
<div class="card-head">
<h2>ISO-привод</h2>
<span class="pill">cdrom</span>
</div>
{% if current_iso %}
<div class="notice">Сейчас подключен ISO: <b>{{ current_iso }}</b></div>
{% else %}
<div class="muted small-note">ISO сейчас не подключён.</div>
{% endif %}
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid">
<label>
<span>ISO образ</span>
<select name="iso_path" required>
{% for iso in isos %}
<option value="{{ iso.path }}" {% if current_iso == iso.path %}selected{% endif %}>{{ iso.name }} — {{ iso.size }}</option>
{% endfor %}
</select>
</label>
<button class="primary wide" type="submit" {% if not isos %}disabled{% endif %}>Смонтировать ISO в VM</button>
</form>
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');">
<button type="submit" class="ghost wide">Отмонтировать ISO</button>
</form>
{% if not isos %}
<div class="alert danger">ISO-образов пока нет. Сначала загрузи .iso в разделе ISO.</div>
{% endif %}
<p class="muted small-note">Для запущенной VM ISO подключается live и сохраняется в конфигурации. Для выключенной VM ISO будет доступен при следующем старте.</p>
</article>
'''
boot_card = r'''
<article class="card">
<div class="card-head">
<h2>Порядок загрузки</h2>
<span class="pill">drag boot</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form">
<input type="hidden" name="boot_order" id="boot-order-value" value="{{ current_boot_order }}">
<div class="boot-order-list" id="boot-order-list" data-current="{{ current_boot_order }}">
<div class="boot-order-item" draggable="true" data-device="cdrom"><span class="drag-handle">☰</span><div><b>ISO / CD-ROM</b><small>Установщик или rescue-образ</small></div></div>
<div class="boot-order-item" draggable="true" data-device="hd"><span class="drag-handle">☰</span><div><b>Диск</b><small>Основной qcow2/raw диск VM</small></div></div>
<div class="boot-order-item" draggable="true" data-device="network"><span class="drag-handle">☰</span><div><b>Сеть / PXE</b><small>Загрузка по сети</small></div></div>
</div>
<button class="primary wide" type="submit">Применить порядок загрузки</button>
</form>
<p class="muted small-note">Перетащи нужный источник выше. Для загрузки с ISO поставь ISO / CD-ROM первым. Изменение сработает после перезапуска VM.</p>
<script>
(function(){
const list = document.getElementById('boot-order-list');
const input = document.getElementById('boot-order-value');
if (!list || !input) return;
const initialMap = {'cdrom_disk':['cdrom','hd','network'],'disk_cdrom':['hd','cdrom','network'],'network_disk':['network','hd','cdrom'],'disk':['hd','cdrom','network'],'auto':['hd','cdrom','network']};
const order = initialMap[list.dataset.current || 'auto'] || initialMap.auto;
const nodes = Array.from(list.querySelectorAll('.boot-order-item'));
order.forEach(device => { const node = nodes.find(item => item.dataset.device === device); if (node) list.appendChild(node); });
function updateValue(){
const devices = Array.from(list.querySelectorAll('.boot-order-item')).map(item => item.dataset.device);
const first = devices[0]; const second = devices[1];
if (first === 'cdrom' && second === 'hd') input.value = 'cdrom_disk';
else if (first === 'hd' && second === 'cdrom') input.value = 'disk_cdrom';
else if (first === 'network' && second === 'hd') input.value = 'network_disk';
else if (first === 'hd') input.value = 'disk';
else input.value = 'auto';
}
let dragged = null;
list.addEventListener('dragstart', event => { dragged = event.target.closest('.boot-order-item'); if (!dragged) return; dragged.classList.add('dragging'); event.dataTransfer.effectAllowed = 'move'; });
list.addEventListener('dragend', () => { if (dragged) dragged.classList.remove('dragging'); dragged = null; updateValue(); });
list.addEventListener('dragover', event => { event.preventDefault(); const after = Array.from(list.querySelectorAll('.boot-order-item:not(.dragging)')).find(item => { const box = item.getBoundingClientRect(); return event.clientY < box.top + box.height / 2; }); if (!dragged) return; if (after) list.insertBefore(dragged, after); else list.appendChild(dragged); });
updateValue();
})();
</script>
</article>
'''
if template_path.exists():
tpl = template_path.read_text()
original = tpl
settings_grid = '\n\n <section class="grid two vm-boot-iso-grid">\n' + iso_card + boot_card + ' </section>\n'
if 'action="/vm/{{ vm.name }}/iso/mount"' not in tpl and 'vm-boot-iso-grid' not in tpl:
markers = ['\n\n <section class="grid three vm-resource-boot-iso-grid">', '\n\n <section class="grid two">', '\n\n <section class="card">']
for marker in markers:
if marker in tpl:
if 'vm-resource-boot-iso-grid' in marker:
insert_pos = tpl.find(marker) + len(marker)
tpl = tpl[:insert_pos] + '\n' + iso_card + boot_card + tpl[insert_pos:]
changed.append('ISO and boot cards inserted into three-column grid')
else:
tpl = tpl.replace(marker, settings_grid + marker, 1)
changed.append('ISO and boot cards inserted by fallback')
break
else:
changed.append('ISO layout skipped: safe template marker not found')
elif 'vm-boot-iso-grid' in tpl and 'boot-order-list' not in tpl:
changed.append('ISO grid exists but draggable boot marker absent; left unchanged safely')
else:
changed.append('ISO mount template already present')
if tpl != original:
template_path.write_text(tpl)
else:
changed.append('ISO template skipped: vm_detail.html not found')
print('existing VM ISO mount patch applied:')
for item in changed:
print(f'- {item}')
-205
Просмотреть файл
@@ -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}')
-154
Просмотреть файл
@@ -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}')
-166
Просмотреть файл
@@ -1,166 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
templates_dir = app_path.parent / 'templates'
static_dir = app_path.parent / 'static'
text = app_path.read_text()
changed = []
helpers = r'''
LOG_SOURCES = {
"web": {"title": "Virtuality Web", "kind": "journal", "unit": "virtuality-web.service"},
"update": {"title": "Update Center", "kind": "file", "path": "/var/log/virtuality/update.log"},
"install": {"title": "Последняя установка", "kind": "glob", "pattern": "/var/log/virtuality/install_web_panel_*.log"},
"operations": {"title": "Операции", "kind": "operations"},
"auto-update": {"title": "Auto Update", "kind": "journal", "unit": "virtuality-auto-update.service"},
"libvirtd": {"title": "libvirtd", "kind": "journal", "unit": "libvirtd.service"},
"virtlogd": {"title": "virtlogd", "kind": "journal", "unit": "virtlogd.service"},
}
def read_log_source(source: str, lines: int = 220) -> dict[str, Any]:
key = source if source in LOG_SOURCES else "web"
cfg = LOG_SOURCES[key]
lines = max(20, min(int(lines or 220), 2000))
content = ""
path = ""
cmd = ""
if cfg["kind"] == "journal":
unit = cfg["unit"]
cmd = f"journalctl -u {unit} -n {lines} --no-pager"
result = run_cmd(["journalctl", "-u", unit, "-n", str(lines), "--no-pager"], timeout=15)
content = result["stdout"] or result["stderr"] or "Лог пуст или journalctl недоступен"
elif cfg["kind"] == "file":
path = cfg["path"]
cmd = f"tail -n {lines} {path}"
content = tail_text(Path(path), max_lines=lines) or "Файл лога пока пуст или не найден"
elif cfg["kind"] == "glob":
pattern = cfg["pattern"]
files = sorted(Path('/').glob(pattern.lstrip('/')), key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True)
if files:
path = str(files[0])
cmd = f"tail -n {lines} {path}"
content = tail_text(files[0], max_lines=lines) or "Файл лога пуст"
else:
cmd = f"ls {pattern}"
content = "Логи установки ещё не найдены"
elif cfg["kind"] == "operations":
cmd = f"tail -n {lines} /var/log/virtuality/operations/*.log"
parts = []
ensure_operations_dir()
for item in sorted(OPERATIONS_DIR.glob('*.log'), key=lambda p: p.stat().st_mtime, reverse=True)[:10]:
parts.append(f"===== {item.name} =====\n" + tail_text(item, max_lines=max(20, lines // 5)))
content = "\n\n".join(parts) or "Журналы операций пока пусты"
return {"key": key, "title": cfg["title"], "content": content, "path": path, "cmd": cmd, "lines": lines}
'''
if 'LOG_SOURCES = {' not in text:
markers = [
'\n\ndef parse_virsh_list() -> list[dict[str, str]]:',
'\n\ndef list_recent_operations(limit: int = 20) -> list[dict[str, Any]]:',
'\n\ndef safe_iso_filename(filename: str) -> str | None:',
]
for marker in markers:
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('log center helpers added')
break
else:
print('WARN: log helper marker not found, skip helper injection')
changed.append('log center helpers skipped')
else:
start = text.find('LOG_SOURCES = {')
end = text.find('\n}\n\n\ndef read_log_source', start)
if start != -1 and end != -1:
replacement = helpers.strip().split('\n\n\ndef read_log_source', 1)[0]
text = text[:start] + replacement + text[end + 3:]
changed.append('log sources refreshed without telegram')
else:
text = text.replace(' "telegram": {"title": "Telegram notifier", "kind": "file", "path": "/var/log/virtuality/telegram_version_bot.log"},\n', '')
changed.append('telegram log source removed')
route = r'''
@app.get("/logs", response_class=HTMLResponse)
def logs_page(request: Request, source: str = "web", lines: int = 220):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
selected = read_log_source(source, lines)
return templates.TemplateResponse("logs.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"sources": LOG_SOURCES,
"selected": selected,
})
@app.get("/api/logs")
def api_logs(request: Request, source: str = "web", lines: int = 220):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return {"ok": True, "log": read_log_source(source, lines)}
'''
if '@app.get("/logs"' not in text:
markers = ['\n\n@app.get("/vm/create", response_class=HTMLResponse)', '\n\n@app.get("/api/operations")', '\n\n@app.get("/iso", response_class=HTMLResponse)']
for marker in markers:
if marker in text:
text = text.replace(marker, route + marker, 1)
changed.append('logs routes added')
break
else:
print('WARN: logs route insert marker not found, skip route injection')
changed.append('logs routes skipped')
else:
changed.append('logs routes already present')
app_path.write_text(text)
sidebar_html = '''{% set path = request.url.path %}
<aside class="v-sidebar">
<div class="v-logo">
<strong>Virtuality</strong>
<span>control panel</span>
</div>
<nav class="v-nav">
<a class="{{ 'active' if path == '/' else '' }}" href="/">Обзор</a>
<a class="{{ 'active' if path.startswith('/vm/create') else '' }}" href="/vm/create">Создать VM</a>
<div class="v-nav-group {{ 'open' if path.startswith('/iso') or path.startswith('/disk-images') else '' }}">
<button class="v-nav-group-title" type="button">Образы</button>
<a class="{{ 'active' if path.startswith('/iso') else '' }}" href="/iso">ISO</a>
<a class="{{ 'active' if path.startswith('/disk-images') else '' }}" href="/disk-images">Диски</a>
</div>
<a class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
<a class="{{ 'active' if path.startswith('/operations') else '' }}" href="/operations">Операции</a>
<a class="{{ 'active' if path.startswith('/logs') else '' }}" href="/logs">Журналы</a>
<a class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
<a class="{{ 'active' if path.startswith('/host') else '' }}" href="/host">Хост</a>
</nav>
</aside>
'''
if templates_dir.exists():
sidebar_path = templates_dir / '_sidebar.html'
sidebar_path.write_text(sidebar_html)
changed.append('_sidebar.html ensured')
for path in sorted(templates_dir.glob('*.html')):
if path.name in {'login.html', 'console.html', '_sidebar.html'}:
continue
html = path.read_text()
if '<script src="/static/panel.js" defer></script>' not in html:
html = html.replace('</body>', ' <script src="/static/panel.js" defer></script>\n</body>', 1)
changed.append(f'{path.name} panel.js attached')
path.write_text(html)
print('logs center and UI dynamics patch applied:')
for item in changed:
print(f'- {item}')
-148
Просмотреть файл
@@ -1,148 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
core_path = app_path.with_name('network_core.py')
if not core_path.exists():
raise SystemExit(f'network_core.py not found: {core_path}')
text = core_path.read_text()
changed = []
if 'def route_interface_for_ip(' not in text:
marker = '''def external_interface() -> str:
result = run_cmd(['ip', 'route', 'show', 'default'], timeout=5)
if not result['ok']:
return 'eth0'
match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout'])
return match.group(1) if match else 'eth0'
'''
helper = marker + '''
def route_interface_for_ip(ip: str) -> str:
if not valid_ip(ip):
return NAT_BRIDGE
result = run_cmd(['ip', 'route', 'get', ip], timeout=5)
if not result['ok']:
return NAT_BRIDGE
match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout'])
return match.group(1) if match else NAT_BRIDGE
def forward_guest_interface(item: dict[str, Any]) -> str:
return route_interface_for_ip(str(item.get('guest_ip', '')))
'''
if marker not in text:
raise SystemExit('external_interface marker not found')
text = text.replace(marker, helper, 1)
changed.append('route_interface_for_ip helper added')
else:
changed.append('route_interface_for_ip helper already present')
old_render = '''def render_nft_rules() -> str:
ext = external_interface()
lines = [
'table ip virtuality {',
' chain prerouting {',
' type nat hook prerouting priority dstnat; policy accept;',
]
for item in load_port_forwards():
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
lines += [
' }',
' chain postrouting {',
' type nat hook postrouting priority srcnat; policy accept;',
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
' }',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
f' ip saddr {NAT_SUBNET} accept',
f' ip daddr {NAT_SUBNET} accept',
' }',
'}',
]
return '\\n'.join(lines) + '\\n'
'''
new_render = '''def render_nft_rules() -> str:
ext = external_interface()
forwards = load_port_forwards()
lines = [
'table ip virtuality {',
' chain prerouting {',
' type nat hook prerouting priority dstnat; policy accept;',
]
for item in forwards:
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
lines += [
' }',
' chain postrouting {',
' type nat hook postrouting priority srcnat; policy accept;',
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' ip daddr {item["guest_ip"]} oifname "{guest_iface}" masquerade')
lines += [
' }',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
f' ip saddr {NAT_SUBNET} accept',
f' ip daddr {NAT_SUBNET} accept',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' iifname "{ext}" oifname "{guest_iface}" ip daddr {item["guest_ip"]} accept')
lines.append(f' iifname "{guest_iface}" oifname "{ext}" ip saddr {item["guest_ip"]} ct state established,related accept')
lines += [
' }',
'}',
]
return '\\n'.join(lines) + '\\n'
'''
if old_render in text:
text = text.replace(old_render, new_render, 1)
changed.append('nft rules now support bridge/static VM interfaces')
elif 'forward_guest_interface(item)' in text and 'ip daddr {item["guest_ip"]}' in text:
changed.append('nft bridge/static rules already present')
else:
raise SystemExit('render_nft_rules marker not found')
text = text.replace(
"'out', 'on', NAT_BRIDGE,\n 'to', item['guest_ip'],",
"'out', 'on', forward_guest_interface(item),\n 'to', item['guest_ip'],",
)
if "'out', 'on', forward_guest_interface(item)" in text:
changed.append('UFW route rules now use guest route interface')
old_iptables_line = """ results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', NAT_BRIDGE, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', NAT_BRIDGE, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
"""
new_iptables_line = """ guest_iface = forward_guest_interface(item)
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, '-j', 'MASQUERADE']))
"""
if old_iptables_line in text:
text = text.replace(old_iptables_line, new_iptables_line, 1)
changed.append('iptables fallback now supports bridge/static VM interfaces')
elif "guest_iface = forward_guest_interface(item)" in text and "'-o', guest_iface" in text:
changed.append('iptables bridge/static fallback already present')
else:
raise SystemExit('iptables fallback marker not found')
text = text.replace(
"'watch_internal': f\"sudo tcpdump -ni {NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'\",",
"'watch_internal': f\"sudo tcpdump -ni {route_interface_for_ip(vm_ip) if vm_ip else NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'\",",
)
core_path.write_text(text)
print('network bridge forward patch applied:')
for item in changed:
print(f'- {item}')
-43
Просмотреть файл
@@ -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}')
-25
Просмотреть файл
@@ -1,25 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
template_path = app_path.with_name('templates') / 'network.html'
if not template_path.exists():
raise SystemExit(f'network.html not found: {template_path}')
text = template_path.read_text()
old = '<input type="hidden" name="guest_ip" value="auto">'
new = '''<label>
<span>IP VM</span>
<input type="text" name="guest_ip" value="auto" placeholder="auto или 192.168.100.55" required>
<small>Если IP не определяется автоматически, впиши внутренний IP VM вручную.</small>
</label>'''
if old in text:
text = text.replace(old, new, 1)
template_path.write_text(text)
print('network manual IP field added')
elif 'name="guest_ip" value="auto" placeholder="auto' in text:
print('network manual IP field already present')
else:
raise SystemExit('guest_ip marker not found in network.html')
-126
Просмотреть файл
@@ -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}')
-143
Просмотреть файл
@@ -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}')
-53
Просмотреть файл
@@ -1,53 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
app_dir = app_path.resolve().parent if app_path.exists() else Path('/opt/virtuality/web')
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
if not template_path.exists():
print(f'legacy boot order cleanup skipped: vm_detail.html not found: {template_path}')
raise SystemExit(0)
tpl = template_path.read_text()
original = tpl
legacy_pattern = re.compile(
r'\n\s*<section class="card">\s*\n'
r'\s*<div class="card-head">\s*\n'
r'\s*<h2>Порядок загрузки</h2>\s*\n'
r'\s*<span class="pill">boot order</span>\s*\n'
r'.*?'
r'\s*</section>\s*\n',
re.DOTALL,
)
tpl, count = legacy_pattern.subn('\n', tpl)
if count:
changed.append(f'legacy select boot-order card removed: {count}')
# Safety cleanup for any accidental duplicated plain select card without the old pill text.
plain_select_pattern = re.compile(
r'\n\s*<section class="card">\s*\n'
r'\s*<div class="card-head">\s*\n'
r'\s*<h2>Порядок загрузки</h2>.*?'
r'<select name="boot_order" required>.*?'
r'\s*</section>\s*\n',
re.DOTALL,
)
tpl, count2 = plain_select_pattern.subn('\n', tpl)
if count2:
changed.append(f'legacy plain select boot-order card removed: {count2}')
if tpl != original:
template_path.write_text(tpl)
else:
changed.append('legacy boot-order select card not found')
print('legacy boot order cleanup patch applied:')
for item in changed:
print(f'- {item}')
-60
Просмотреть файл
@@ -1,60 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
templates_dir = app_path.parent / 'templates'
if not templates_dir.exists():
raise SystemExit(f'templates dir not found: {templates_dir}')
skip = {'login.html', 'console.html', '_sidebar.html'}
changed = []
skipped = []
for path in sorted(templates_dir.glob('*.html')):
if path.name in skip:
skipped.append(f'{path.name}: skipped system/template')
continue
text = path.read_text()
if '{% include "_sidebar.html" %}' in text or "{% include '_sidebar.html' %}" in text:
skipped.append(f'{path.name}: already has sidebar')
continue
if '<div class="v-layout">' in text:
skipped.append(f'{path.name}: already v-layout')
continue
if '<div class="shell">' not in text:
skipped.append(f'{path.name}: no shell')
continue
text = text.replace(
'<body>\n <div class="shell">',
'<body>\n <div class="v-layout">\n {% include "_sidebar.html" %}\n <main class="v-main">\n <div class="shell v-shell-embedded">',
1,
)
# Close shell/main/layout before scripts when page has JS block.
marker = '\n\n <script>'
if marker in text:
idx = text.rfind('\n </div>', 0, text.find(marker))
if idx != -1:
text = text[:idx] + '\n </div>\n </main>\n </div>' + text[idx + len('\n </div>'):]
else:
skipped.append(f'{path.name}: close marker before script not found')
continue
else:
marker2 = '\n</body>'
idx = text.rfind('\n </div>', 0, text.find(marker2) if marker2 in text else len(text))
if idx != -1:
text = text[:idx] + '\n </div>\n </main>\n </div>' + text[idx + len('\n </div>'):]
else:
skipped.append(f'{path.name}: close marker before body not found')
continue
path.write_text(text)
changed.append(path.name)
print('sidebar layout patch applied:')
for name in changed:
print(f'- {name}: sidebar wrapped')
for item in skipped:
print(f'- {item}')
-56
Просмотреть файл
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
if 'import update_core' not in text:
text = text.replace('import network_core\n', 'import network_core\nimport update_core\n', 1)
changed.append('import update_core added')
helper = r'''
def dashboard_update_notice() -> dict[str, Any]:
try:
info = update_core.check_updates(fetch=False)
if info.get("has_update"):
return {
"has_update": True,
"current_version": info.get("current_version", "unknown"),
"latest_version": info.get("latest_version", "unknown"),
"missing_count": len(info.get("missing_versions", [])),
"commit_count": len(info.get("commits", [])),
}
except Exception:
pass
return {"has_update": False}
'''
if 'def dashboard_update_notice() -> dict[str, Any]:' not in text:
marker = '\n\ndef update_operation(operation: dict[str, Any], **changes: Any) -> None:'
if marker not in text:
raise SystemExit('update_operation marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('dashboard update notice helper added')
else:
changed.append('dashboard update notice helper already present')
old_fragment = '"operations": list_operations(5), "operation_css": operation_css})'
new_fragment = '"operations": list_operations(5), "operation_css": operation_css, "update_notice": dashboard_update_notice()})'
if old_fragment in text:
text = text.replace(old_fragment, new_fragment, 1)
changed.append('dashboard context gets update_notice')
elif '"update_notice": dashboard_update_notice()' in text:
changed.append('dashboard context already has update_notice')
else:
raise SystemExit('dashboard context marker not found')
app_path.write_text(text)
print('update badge patch applied:')
for item in changed:
print(f'- {item}')
-127
Просмотреть файл
@@ -1,127 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
if 'import update_core' not in text:
if 'import network_core\n' in text:
text = text.replace('import network_core\n', 'import network_core\nimport update_core\n', 1)
elif 'from network_core import NetworkError\n' in text:
text = text.replace('from network_core import NetworkError\n', 'from network_core import NetworkError\nimport update_core\n', 1)
else:
raise SystemExit('network_core import marker not found')
changed.append('import update_core added')
else:
changed.append('import update_core already present')
routes = r'''
@app.get("/update", response_class=HTMLResponse)
def update_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
error = None
try:
info = update_core.check_updates(fetch=False)
except Exception as exc:
error = str(exc)
info = {
"ok": False,
"source_dir": str(update_core.SOURCE_DIR),
"remote": update_core.REMOTE,
"branch": update_core.DEFAULT_BRANCH,
"fetch_ok": False,
"fetch_error": str(exc),
"current_commit": "",
"latest_commit": "",
"current_version": "unknown",
"latest_version": "unknown",
"has_update": False,
"missing_versions": [],
"commits": [],
"checked_at": utc_now(),
"state": update_core.state(),
"log_tail": update_core.update_log_tail(),
}
return templates.TemplateResponse("update.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error})
@app.get("/update/status")
def update_status(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return JSONResponse({"ok": False, "error": "auth required"}, status_code=401)
return JSONResponse({
"ok": True,
"state": update_core.state(),
"log_tail": update_core.update_log_tail(260),
"checked_at": utc_now(),
})
@app.post("/update/check")
def update_check(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
update_core.check_updates(fetch=True)
except Exception:
pass
return RedirectResponse(url="/update", status_code=303)
@app.post("/update/apply")
def update_apply(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
update_core.start_update()
except Exception:
pass
return RedirectResponse(url="/update", status_code=303)
'''
if '@app.get("/update"' not in text:
marker = '\n\n@app.get("/operations", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('operations route marker not found')
text = text.replace(marker, routes + marker, 1)
changed.append('update routes added')
else:
changed.append('update route already present')
if '@app.get("/update/status"' not in text:
marker = '\n\n@app.post("/update/check")'
if marker not in text:
raise SystemExit('update/check route marker not found')
status_route = r'''
@app.get("/update/status")
def update_status(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return JSONResponse({"ok": False, "error": "auth required"}, status_code=401)
return JSONResponse({
"ok": True,
"state": update_core.state(),
"log_tail": update_core.update_log_tail(260),
"checked_at": utc_now(),
})
'''
text = text.replace(marker, status_route + marker, 1)
changed.append('update status route added')
else:
changed.append('update status route already present')
app_path.write_text(text)
print('update center patch applied:')
for item in changed:
print(f'- {item}')
-96
Просмотреть файл
@@ -1,96 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
safe_name_helper = r'''
def safe_upload_filename(filename: str, allowed_suffixes: tuple[str, ...], fallback_prefix: str) -> str | None:
original = Path(filename or "").name.strip()
suffix = Path(original).suffix.lower()
if suffix not in allowed_suffixes:
return None
stem = Path(original).stem.strip()
stem = re.sub(r"\s+", "-", stem)
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
stem = stem.strip("._-")
if not stem:
stem = fallback_prefix
stem = stem[:120]
name = f"{stem}{suffix}"
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,180}\.[a-zA-Z0-9]{2,8}", name):
name = f"{fallback_prefix}{suffix}"
return name
'''
if 'def safe_upload_filename(' not in text:
marker = '\n\ndef safe_iso_filename(filename: str) -> str | None:'
if marker not in text:
raise SystemExit('safe_iso_filename marker not found')
text = text.replace(marker, safe_name_helper + marker, 1)
changed.append('safe upload filename helper added')
else:
changed.append('safe upload filename helper already present')
old_iso = r'''def safe_iso_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith(".iso"):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.iso", name):
return None
return name
'''
new_iso = r'''def safe_iso_filename(filename: str) -> str | None:
return safe_upload_filename(filename, (".iso",), "virtuality-iso")
'''
if old_iso in text:
text = text.replace(old_iso, new_iso, 1)
changed.append('ISO filename sanitizer relaxed')
elif 'return safe_upload_filename(filename, (".iso",), "virtuality-iso")' in text:
changed.append('ISO filename sanitizer already relaxed')
else:
changed.append('ISO filename sanitizer replacement skipped')
old_disk = r'''def safe_disk_image_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith((".img", ".raw", ".qcow2")):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(img|raw|qcow2)", name, re.IGNORECASE):
return None
return name
'''
new_disk = r'''def safe_disk_image_filename(filename: str) -> str | None:
return safe_upload_filename(filename, (".img", ".raw", ".qcow2"), "virtuality-disk")
'''
if old_disk in text:
text = text.replace(old_disk, new_disk, 1)
changed.append('disk image filename sanitizer relaxed')
elif 'return safe_upload_filename(filename, (".img", ".raw", ".qcow2"), "virtuality-disk")' in text:
changed.append('disk image filename sanitizer already relaxed')
else:
changed.append('disk image filename sanitizer not found yet')
# Make upload errors more explicit when temp/filesystem fails after multipart parsing.
text = text.replace(
'"error": f"Ошибка загрузки ISO: {exc}"}, status_code=500)',
'"error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)'
)
text = text.replace(
'"error": f"Ошибка загрузки образа: {exc}"}, status_code=500)',
'"error": f"Ошибка загрузки образа: {exc}. Проверь свободное место, права на /var/lib/virtuality/disk-images и временный каталог /var/lib/virtuality/tmp."}, status_code=500)'
)
changed.append('upload error messages clarified')
app_path.write_text(text)
print('upload compatibility patch applied:')
for item in changed:
print(f'- {item}')
-73
Просмотреть файл
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_dir = Path(sys.argv[1]).resolve().parent if len(sys.argv) > 1 else Path('/opt/virtuality/web')
templates_dir = app_dir / 'templates'
changed = []
guard_script = r'''
function setUploadNavigationGuard(active) {
window.virtualityUploadActive = Boolean(active);
document.body.classList.toggle('upload-locked', window.virtualityUploadActive);
}
window.addEventListener('beforeunload', function (event) {
if (!window.virtualityUploadActive) return;
event.preventDefault();
event.returnValue = 'Идёт загрузка файла. Переход остановит передачу.';
return event.returnValue;
});
document.addEventListener('click', function (event) {
if (!window.virtualityUploadActive) return;
const target = event.target.closest('a, button, input[type="submit"]');
if (!target) return;
if (target.id === 'iso-upload-button' || target.id === 'disk-upload-button') return;
event.preventDefault();
event.stopPropagation();
alert('Идёт загрузка файла. Дождись завершения или нажми «Отменить загрузку». Переход по меню сейчас заблокирован, чтобы файл не оборвался.');
}, true);
document.addEventListener('submit', function (event) {
if (!window.virtualityUploadActive) return;
if (event.target && (event.target.id === 'iso-upload-form' || event.target.id === 'disk-upload-form')) return;
event.preventDefault();
event.stopPropagation();
alert('Идёт загрузка файла. Дождись завершения или нажми «Отменить загрузку».');
}, true);
'''
def patch_template(path: Path, active_token: str, form_id: str) -> None:
if not path.exists():
return
text = path.read_text()
original = text
if 'function setUploadNavigationGuard(active)' not in text:
marker = ' function bytesText(bytes) {'
if marker not in text:
raise SystemExit(f'bytesText marker not found in {path}')
text = text.replace(marker, guard_script + '\n' + marker, 1)
text = text.replace(' uploadActive = active;\n', ' uploadActive = active;\n setUploadNavigationGuard(active);\n')
# During server-side post-processing after upload, file transfer is done, so page navigation is safe again.
text = text.replace(" window.location.href = '/iso';", " setUploadNavigationGuard(false);\n window.location.href = '/iso';")
text = text.replace(" window.location.href = '/disk-images';", " setUploadNavigationGuard(false);\n window.location.href = '/disk-images';")
# Conversion polling is not an active browser upload anymore; allow navigation while backend operation continues.
text = text.replace(" setUploadMode(false);\n fileInput.disabled = true;", " setUploadMode(false);\n setUploadNavigationGuard(false);\n fileInput.disabled = true;")
if text != original:
path.write_text(text)
changed.append(str(path))
patch_template(templates_dir / 'iso.html', 'iso-upload-button', 'iso-upload-form')
patch_template(templates_dir / 'disk_images.html', 'disk-upload-button', 'disk-upload-form')
print('upload navigation guard patch applied:')
for item in changed or ['already applied']:
print(f'- {item}')
-147
Просмотреть файл
@@ -1,147 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helpers = r'''
def vm_arch_options(profile: dict[str, Any]) -> list[dict[str, str]]:
host_arch = str(profile.get("arch", ""))
options = [
{"value": "auto", "label": "Авто — рекомендовано для этого хоста"},
{"value": "x86_64", "label": "x86_64 / amd64 — обычные ПК и серверы"},
]
if host_arch in ("aarch64", "arm64"):
options.append({"value": "aarch64", "label": "ARM64 / aarch64 — KVM на ARM-хосте"})
else:
options.append({"value": "aarch64", "label": "ARM64 / aarch64 — QEMU-эмуляция, медленно"})
options.append({"value": "generic", "label": "Generic QEMU — экспериментально"})
return options
def resolve_vm_arch(arch_choice: str, profile: dict[str, Any]) -> str:
if arch_choice == "auto" or not arch_choice:
return str(profile.get("recommended_guest_arch") or "x86_64")
if arch_choice in ("x86_64", "aarch64", "generic"):
return arch_choice
return str(profile.get("recommended_guest_arch") or "x86_64")
def append_arch_args(cmd: list[str], guest_arch: str, profile: dict[str, Any]) -> list[str]:
host_arch = str(profile.get("arch", ""))
if guest_arch == "aarch64":
if host_arch in ("aarch64", "arm64"):
return cmd + ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
return cmd + ["--arch", "aarch64", "--machine", "virt", "--cpu", "cortex-a57", "--virt-type", "qemu", "--boot", "uefi"]
if guest_arch == "x86_64":
if host_arch in ("x86_64", "amd64"):
return cmd + ["--arch", "x86_64"]
return cmd + ["--arch", "x86_64", "--virt-type", "qemu"]
return cmd
'''
start = text.find('def vm_arch_options(profile: dict[str, Any])')
if start != -1:
end = text.find('\n\ndef vm_form_context(', start)
if end == -1:
raise SystemExit('vm_form_context end marker not found')
text = text[:start] + helpers.strip() + text[end:]
changed.append('architecture helpers replaced')
else:
marker = '\n\ndef vm_form_context('
if marker not in text:
raise SystemExit('vm_form_context marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('architecture helpers added')
old_context = '"disk_images": list_disk_image_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
new_context = '"disk_images": list_disk_image_files(), "arch_options": vm_arch_options(profile), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
if old_context in text:
text = text.replace(old_context, new_context, 1)
changed.append('vm form context gets arch options')
elif '"arch_options": vm_arch_options(profile)' in text:
changed.append('vm form context already has arch options')
else:
old_context2 = '"isos": list_iso_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
new_context2 = '"isos": list_iso_files(), "disk_images": list_disk_image_files() if "list_disk_image_files" in globals() else [], "arch_options": vm_arch_options(profile), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
if old_context2 in text:
text = text.replace(old_context2, new_context2, 1)
changed.append('vm form context gets arch options fallback')
else:
raise SystemExit('vm_form_context payload marker not found')
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig in text:
text = text.replace(old_sig, new_sig, 1)
changed.append('vm create signature gets guest_arch')
elif new_sig in text:
changed.append('vm create signature already has guest_arch')
else:
old_sig2 = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig2 = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig2 in text:
text = text.replace(old_sig2, new_sig2, 1)
changed.append('vm create signature gets guest_arch fallback')
else:
raise SystemExit('vm_create_submit signature marker not found')
old_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "network_mode": network_mode, "bridge": bridge}'
new_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
if old_form in text:
text = text.replace(old_form, new_form, 1)
changed.append('form state stores guest_arch')
elif '"guest_arch": guest_arch' in text:
changed.append('form state already stores guest_arch')
else:
old_form2 = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}'
new_form2 = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
if old_form2 in text:
text = text.replace(old_form2, new_form2, 1)
changed.append('form state stores guest_arch fallback')
old_validation = 'elif source_type not in ("iso", "disk_image"):\n error = "Некорректный источник VM."'
new_validation = 'elif source_type not in ("iso", "disk_image"):\n error = "Некорректный источник VM."\n elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."'
if old_validation in text and 'Некорректная архитектура VM' not in text:
text = text.replace(old_validation, new_validation, 1)
changed.append('guest_arch validation added')
elif 'Некорректная архитектура VM' in text:
changed.append('guest_arch validation already present')
old_arch_block = ''' profile = host_profile.load_host_profile()
is_arm = profile.get("recommended_guest_arch") == "aarch64"
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
if is_arm:
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
'''
new_arch_block = ''' profile = host_profile.load_host_profile()
resolved_guest_arch = resolve_vm_arch(guest_arch, profile)
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
cmd = append_arch_args(cmd, resolved_guest_arch, profile)
'''
if old_arch_block in text:
text = text.replace(old_arch_block, new_arch_block, 1)
changed.append('virt-install arch args made selectable')
elif 'cmd = append_arch_args(cmd, resolved_guest_arch)' in text:
text = text.replace('cmd = append_arch_args(cmd, resolved_guest_arch)', 'cmd = append_arch_args(cmd, resolved_guest_arch, profile)')
changed.append('virt-install arch args now use host profile')
elif 'cmd = append_arch_args(cmd, resolved_guest_arch, profile)' in text:
changed.append('virt-install arch args already use host profile')
else:
raise SystemExit('arch command block marker not found')
text = text.replace('"guest_arch": profile.get("recommended_guest_arch"),', '"guest_arch": resolved_guest_arch, "guest_arch_choice": guest_arch,')
text = text.replace('"guest_arch": profile.get("recommended_guest_arch")', '"guest_arch": resolved_guest_arch, "guest_arch_choice": guest_arch')
app_path.write_text(text)
print('vm architecture patch applied:')
for item in changed:
print(f'- {item}')
-67
Просмотреть файл
@@ -1,67 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = r'''
def vm_autostart_status(name: str) -> dict[str, str | bool]:
result = run_cmd(["virsh", "dominfo", name], timeout=8)
output = result.get("stdout", "") or ""
match = re.search(r"^Autostart:\s*(.+)$", output, re.MULTILINE | re.IGNORECASE)
raw = match.group(1).strip() if match else "unknown"
enabled = raw.lower() in ("enable", "enabled", "yes", "on")
label = "enabled" if enabled else "disabled" if raw != "unknown" else "unknown"
css = "ok" if enabled else "warn"
return {"enabled": enabled, "label": label, "css": css, "raw": raw}
'''
if 'def vm_autostart_status(' not in text:
marker = '\n\ndef parse_virsh_list() -> list[dict[str, str]]:'
if marker not in text:
raise SystemExit('parse_virsh_list marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('vm_autostart_status helper added')
else:
changed.append('vm_autostart_status helper already present')
old_rows = ''' if len(parts) == 3:\n rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})\n elif len(parts) == 2:\n rows.append({"id": "-", "name": parts[0], "state": parts[1]})'''
new_rows = ''' if len(parts) == 3:\n autostart = vm_autostart_status(parts[1])\n rows.append({"id": parts[0], "name": parts[1], "state": parts[2], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})\n elif len(parts) == 2:\n autostart = vm_autostart_status(parts[0])\n rows.append({"id": "-", "name": parts[0], "state": parts[1], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})'''
if old_rows in text:
text = text.replace(old_rows, new_rows, 1)
changed.append('parse_virsh_list enriched with autostart')
elif 'autostart_enabled' in text and 'autostart_label' in text:
changed.append('parse_virsh_list already has autostart')
else:
raise SystemExit('parse_virsh_list row marker not found')
old_details = '''def vm_details(name: str) -> dict[str, Any]:\n return {\n "name": name,\n "dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],\n "vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",\n "ip": vm_ip(name),\n "disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],\n "interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],\n "autostart": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],\n }'''
new_details = '''def vm_details(name: str) -> dict[str, Any]:\n dominfo = run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"]\n autostart = vm_autostart_status(name)\n return {\n "name": name,\n "dominfo": dominfo,\n "vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",\n "ip": vm_ip(name),\n "disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],\n "interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],\n "autostart": dominfo,\n "autostart_enabled": autostart["enabled"],\n "autostart_label": autostart["label"],\n "autostart_css": autostart["css"],\n }'''
if old_details in text:
text = text.replace(old_details, new_details, 1)
changed.append('vm_details enriched with autostart')
elif '"autostart_enabled": autostart["enabled"]' in text:
changed.append('vm_details already has autostart')
else:
raise SystemExit('vm_details marker not found')
old_live = ''' vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else ""})'''
new_live = ''' vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "", "autostart_enabled": vm.get("autostart_enabled", False), "autostart_label": vm.get("autostart_label", "unknown"), "autostart_css": vm.get("autostart_css", "warn")})'''
if old_live in text:
text = text.replace(old_live, new_live, 1)
changed.append('live status enriched with autostart')
elif '"autostart_label": vm.get("autostart_label", "unknown")' in text:
changed.append('live status already has autostart')
else:
changed.append('live status marker not found, skipped')
app_path.write_text(text)
print('vm autostart patch applied:')
for item in changed:
print(f'- {item}')
-214
Просмотреть файл
@@ -1,214 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_create.html'
changed = []
warnings = []
text = app_path.read_text()
helpers = r'''
def vm_boot_order_options() -> list[dict[str, str]]:
return [
{"value": "auto", "label": "Auto — по источнику VM"},
{"value": "disk", "label": "Сначала диск"},
{"value": "cdrom_disk", "label": "Сначала ISO/CD-ROM, потом диск"},
{"value": "disk_cdrom", "label": "Сначала диск, потом ISO/CD-ROM"},
{"value": "network_disk", "label": "Сначала сеть/PXE, потом диск"},
]
def normalize_boot_order(value: str, source_type: str) -> str:
value = (value or "auto").strip()
if value == "auto":
return "cdrom_disk" if source_type == "iso" else "disk"
if value in ("disk", "cdrom_disk", "disk_cdrom", "network_disk"):
return value
return "cdrom_disk" if source_type == "iso" else "disk"
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
mapping = {
"disk": "hd",
"cdrom_disk": "cdrom,hd",
"disk_cdrom": "hd,cdrom",
"network_disk": "network,hd",
}
value = mapping.get(boot_order, "hd")
if is_arm:
return "uefi," + value
return value
'''
def replace_once(source: str, old: str, new: str, label: str) -> str:
if old in source:
changed.append(label)
return source.replace(old, new, 1)
warnings.append(f'{label}: marker not found, skipped')
return source
if 'def vm_boot_order_options()' not in text:
marker = '\n\ndef vm_form_context('
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('boot order helpers added')
else:
warnings.append('vm_form_context marker not found, helpers skipped')
else:
changed.append('boot order helpers already present')
if '"boot_options": vm_boot_order_options()' not in text:
if '"arch_options": vm_arch_options(),' in text:
text = text.replace('"arch_options": vm_arch_options(),', '"arch_options": vm_arch_options(), "boot_options": vm_boot_order_options(),', 1)
changed.append('vm form context gets boot options')
else:
warnings.append('arch_options marker not found, boot_options skipped')
else:
changed.append('vm form context already has boot options')
if '"boot_order": "auto"' not in text:
if '"guest_arch": "auto", "network_mode"' in text:
text = text.replace('"guest_arch": "auto", "network_mode"', '"guest_arch": "auto", "boot_order": "auto", "network_mode"', 1)
changed.append('default form boot_order added')
elif '"guest_arch": "auto"' in text:
text = text.replace('"guest_arch": "auto"', '"guest_arch": "auto", "boot_order": "auto"', 1)
changed.append('default form boot_order added near guest_arch')
else:
warnings.append('default form marker not found, boot_order default skipped')
else:
changed.append('default form boot_order already present')
if 'boot_order: str = Form("auto")' not in text:
signature_patterns = [
('guest_arch: str = Form("auto"), network_mode:', 'guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode:'),
('guest_arch: str = Form("auto"),\n network_mode:', 'guest_arch: str = Form("auto"),\n boot_order: str = Form("auto"),\n network_mode:'),
]
for old, new in signature_patterns:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm create signature supports boot order')
break
else:
warnings.append('vm create signature marker not found, skipped')
else:
changed.append('vm create signature already supports boot order')
if '"boot_order": boot_order' not in text:
if '"guest_arch": guest_arch, "network_mode"' in text:
text = text.replace('"guest_arch": guest_arch, "network_mode"', '"guest_arch": guest_arch, "boot_order": boot_order, "network_mode"', 1)
changed.append('vm create form state supports boot order')
elif '"guest_arch": guest_arch' in text:
text = text.replace('"guest_arch": guest_arch', '"guest_arch": guest_arch, "boot_order": boot_order', 1)
changed.append('vm create form state supports boot order near guest_arch')
else:
warnings.append('vm create form state marker not found, skipped')
else:
changed.append('vm create form state already supports boot order')
if 'boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk")' not in text:
validation_old = 'elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."'
validation_new = validation_old + '\n elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):\n error = "Некорректный порядок загрузки VM."'
if validation_old in text:
text = text.replace(validation_old, validation_new, 1)
changed.append('vm create validation supports boot order')
else:
warnings.append('vm create validation marker not found, skipped')
else:
changed.append('vm create validation already supports boot order')
if 'selected_boot_order = normalize_boot_order(boot_order, source_type)' not in text:
if 'selected_arch = normalize_guest_arch(guest_arch, profile)\n' in text:
text = text.replace('selected_arch = normalize_guest_arch(guest_arch, profile)\n', 'selected_arch = normalize_guest_arch(guest_arch, profile)\n selected_boot_order = normalize_boot_order(boot_order, source_type)\n', 1)
changed.append('selected boot order added')
else:
warnings.append('selected_arch marker not found, selected boot order skipped')
else:
changed.append('selected boot order already present')
# Replace legacy ARM-specific --boot uefi in architecture block with unified --boot argument if present.
if ', "--boot", "uefi"]' in text:
text = text.replace(', "--boot", "uefi"]', ']')
changed.append('legacy ARM --boot uefi removed')
if 'cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]' not in text:
marker = ' if source_type == "disk_image":\n'
if marker in text:
text = text.replace(marker, ' cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]\n\n' + marker, 1)
changed.append('virt-install boot argument added')
else:
warnings.append('source_type command marker not found, boot argument skipped')
else:
changed.append('virt-install boot argument already present')
if '"boot_order": selected_boot_order' not in text:
if '"guest_arch": selected_arch, "host_profile"' in text:
text = text.replace('"guest_arch": selected_arch, "host_profile"', '"guest_arch": selected_arch, "boot_order": selected_boot_order, "host_profile"', 1)
changed.append('operation metadata gets boot order')
elif '"guest_arch": selected_arch' in text:
text = text.replace('"guest_arch": selected_arch', '"guest_arch": selected_arch, "boot_order": selected_boot_order', 1)
changed.append('operation metadata gets boot order near guest_arch')
else:
warnings.append('operation metadata marker not found, skipped')
else:
changed.append('operation metadata already has boot order')
app_path.write_text(text)
if template_path.exists():
tpl = template_path.read_text()
original = tpl
if 'name="boot_order"' not in tpl:
insert = r'''
<label>
<span>Порядок загрузки</span>
<select name="boot_order" required>
{% for boot in boot_options %}
<option value="{{ boot.value }}" {% if form.boot_order == boot.value %}selected{% endif %}>{{ boot.label }}</option>
{% endfor %}
</select>
</label>
'''
markers = [
' <label>\n <span>Режим сети</span>',
' <label>\n <span>Режим сети</span>',
]
for marker in markers:
if marker in tpl:
tpl = tpl.replace(marker, insert + '\n' + marker, 1)
changed.append('boot order field added to vm_create.html')
break
else:
warnings.append('network mode marker not found in vm_create.html, boot field skipped')
else:
changed.append('boot order field already present in vm_create.html')
tpl = tpl.replace(
'Virtuality импортирует .img/.raw/.qcow2 в новый qcow2-диск VM. Поле “Диск, GB” не используется.',
'Virtuality импортирует .img/.raw/.qcow2 в новый qcow2-диск VM. Поле “Диск, GB” не используется. Для готовых дисков обычно выбирай порядок загрузки “Сначала диск”.',
)
tpl = tpl.replace(
'Virtuality создаст новый диск указанного размера и запустит установку с ISO.',
'Virtuality создаст новый диск указанного размера и запустит установку с ISO. Для установщика обычно выбирай “Сначала ISO/CD-ROM, потом диск”.',
)
if tpl != original:
template_path.write_text(tpl)
else:
warnings.append(f'vm_create.html not found: {template_path}')
print('VM boot order patch applied:')
for item in changed:
print(f'- {item}')
if warnings:
print('Warnings:')
for item in warnings:
print(f'- {item}')
-161
Просмотреть файл
@@ -1,161 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
app_dir = app_path.resolve().parent if app_path.exists() else Path('/opt/virtuality/web')
template_path = app_dir / 'templates' / 'vm_detail.html'
css_path = app_dir / 'static' / 'app.css'
changed = []
if not template_path.exists():
print(f'VM detail resource layout patch skipped: vm_detail.html not found: {template_path}')
raise SystemExit(0)
tpl = template_path.read_text()
original = tpl
resource_messages = r'''
{% if resource_message %}
<div class="alert success">{{ resource_message }}</div>
{% endif %}
{% if resource_error %}
<div class="alert danger">{{ resource_error }}</div>
{% endif %}
'''
resource_card = r'''
<article class="card">
<div class="card-head">
<h2>CPU / RAM / Архитектура</h2>
<span class="pill">resources</span>
</div>
<div class="resource-mini-grid">
<div><span>CPU</span><b>{{ resource_settings.vcpus }}</b></div>
<div><span>RAM</span><b>{{ resource_settings.memory_mb }} MB</b></div>
<div><span>Архитектура</span><b>{{ resource_settings.arch }}</b></div>
</div>
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid">
<label>
<span>CPU, vCPU</span>
<input type="number" name="vcpus" min="1" max="128" value="{{ resource_settings.vcpus }}" required {% if not resource_settings.is_shutoff %}disabled{% endif %}>
</label>
<label>
<span>RAM, MB</span>
<input type="number" name="memory_mb" min="512" max="262144" step="128" value="{{ resource_settings.memory_mb }}" required {% if not resource_settings.is_shutoff %}disabled{% endif %}>
</label>
<label>
<span>Архитектура</span>
<select name="guest_arch" {% if not resource_settings.is_shutoff %}disabled{% endif %}>
<option value="keep" selected>Не менять — {{ resource_settings.arch }}</option>
<option value="x86_64">x86_64 / amd64</option>
<option value="aarch64">ARM64 / aarch64</option>
</select>
</label>
<button class="primary wide" type="submit" {% if not resource_settings.is_shutoff %}disabled{% endif %}>Применить ресурсы</button>
</form>
{% if not resource_settings.is_shutoff %}
<div class="alert danger">VM сейчас не выключена. CPU, RAM и архитектуру можно менять только после Shutdown/Power off.</div>
{% endif %}
<p class="muted small-note">CPU/RAM меняются в XML VM. Смена архитектуры требует совместимый диск и загрузчик; x86-диск не станет ARM-диском по щелчку, увы, физика всё ещё душнит.</p>
</article>
'''
if 'resource_message' not in tpl:
marker = ' {% if iso_message %}'
if marker in tpl:
tpl = tpl.replace(marker, resource_messages + '\n' + marker, 1)
changed.append('resource messages added before ISO messages')
else:
# Safe fallback: place messages after header/topbar area if exact ISO marker is not present.
marker = ' <section'
index = tpl.find(marker)
if index != -1:
tpl = tpl[:index] + resource_messages + '\n' + tpl[index:]
changed.append('resource messages added by fallback')
else:
changed.append('resource messages skipped: no safe marker')
if 'CPU / RAM / Архитектура' not in tpl:
candidate_markers = [
' <section class="grid three vm-resource-boot-iso-grid">',
' <section class="grid two vm-boot-iso-grid">',
' <section class="grid two">',
' <section class="card">',
]
inserted = False
for marker in candidate_markers:
pos = tpl.find(marker)
if pos == -1:
continue
if 'vm-boot-iso-grid' in marker:
tpl = tpl.replace(marker, ' <section class="grid three vm-resource-boot-iso-grid">', 1)
grid_pos = tpl.find('vm-resource-boot-iso-grid')
insert_at = tpl.find('\n <article class="card">', grid_pos)
if insert_at != -1:
tpl = tpl[:insert_at] + '\n' + resource_card + tpl[insert_at:]
else:
close_at = tpl.find('</section>', grid_pos)
if close_at != -1:
tpl = tpl[:close_at] + resource_card + tpl[close_at:]
changed.append('resource card inserted into existing settings grid')
inserted = True
break
elif marker == ' <section class="grid two">':
settings = '\n\n <section class="grid three vm-resource-boot-iso-grid">\n' + resource_card + ' </section>\n'
tpl = tpl.replace('\n\n' + marker, settings + '\n\n' + marker, 1)
changed.append('resource grid inserted before first two-column grid')
inserted = True
break
else:
settings = '\n\n <section class="grid three vm-resource-boot-iso-grid">\n' + resource_card + ' </section>\n'
tpl = tpl[:pos] + settings + tpl[pos:]
changed.append('resource grid inserted before first card fallback')
inserted = True
break
if not inserted:
changed.append('resource card skipped: no safe layout marker')
else:
changed.append('resource card already present')
# Upgrade older two-column settings grid when present.
tpl = tpl.replace('class="grid two vm-boot-iso-grid"', 'class="grid three vm-resource-boot-iso-grid"')
if tpl != original:
template_path.write_text(tpl)
if css_path.exists():
css = css_path.read_text()
original_css = css
if '.three {' not in css:
if '.two {' in css:
css = css.replace('.two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; }', '.two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; }\n.three { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 12px; }')
if '.three {' not in css:
css += '\n.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }\n'
else:
css += '\n.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }\n'
changed.append('three-column grid CSS added')
if '.resource-mini-grid' not in css:
css += '''
.resource-mini-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin-bottom: 10px; }
.resource-mini-grid div { display: grid; gap: 3px; padding: 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); }
.resource-mini-grid span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.resource-mini-grid b { font-size: 14px; overflow-wrap: anywhere; }
.boot-order-list { display: grid; gap: 8px; }
.boot-order-item { display: flex; align-items: center; gap: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); cursor: grab; }
.boot-order-item:active { cursor: grabbing; }
.boot-order-item.dragging { opacity: .55; border-color: var(--accent); }
.boot-order-item small { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
.drag-handle { color: var(--muted); font-weight: 900; }
@media (max-width: 1180px) { .three { grid-template-columns: 1fr; } .resource-mini-grid { grid-template-columns: 1fr; } }
'''
changed.append('resource and boot-order CSS added')
if css != original_css:
css_path.write_text(css)
else:
changed.append('CSS skipped: app.css not found')
print('VM detail resource layout patch applied:')
for item in changed or ['already applied']:
print(f'- {item}')
-74
Просмотреть файл
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = r'''
def vm_exists(name: str) -> bool:
if not name or not valid_vm_name(name):
return False
return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"]
'''
if 'def vm_exists(name: str) -> bool:' not in text:
marker = '\n\ndef list_vms() -> list[dict[str, str]]:'
if marker not in text:
raise SystemExit('list_vms marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('vm_exists helper added')
else:
changed.append('vm_exists helper already present')
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), replace_existing_disk: str = Form("0"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig in text:
text = text.replace(old_sig, new_sig, 1)
changed.append('replace_existing_disk added to signature')
elif 'replace_existing_disk: str = Form("0")' in text:
changed.append('replace_existing_disk already in signature')
else:
raise SystemExit('vm_create_submit signature marker not found')
old_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
new_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "replace_existing_disk": replace_existing_disk, "network_mode": network_mode, "bridge": bridge}'
if old_form in text:
text = text.replace(old_form, new_form, 1)
changed.append('replace_existing_disk stored in form')
elif '"replace_existing_disk": replace_existing_disk' in text:
changed.append('replace_existing_disk already stored in form')
old_disk_check = ''' disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
profile = host_profile.load_host_profile()
'''
new_disk_check = ''' disk_path = IMAGES_DIR / f"{name}.qcow2"
if vm_exists(name):
return vm_form_context(request, error=f"VM с именем {name} уже существует. Выбери другое имя или удали существующую VM.", form=form, status_code=400)
if disk_path.exists():
if replace_existing_disk == "1":
disk_path.unlink()
else:
return vm_form_context(request, error=f"Диск уже существует: {disk_path}. Это может быть остаток неудачной установки. Включи опцию «Заменить существующий диск», если VM с таким именем не нужна.", form=form, status_code=400)
profile = host_profile.load_host_profile()
'''
if old_disk_check in text:
text = text.replace(old_disk_check, new_disk_check, 1)
changed.append('disk replace logic added')
elif 'Заменить существующий диск' in text and 'replace_existing_disk == "1"' in text:
changed.append('disk replace logic already present')
else:
raise SystemExit('disk_path exists marker not found')
app_path.write_text(text)
print('vm disk replace patch applied:')
for item in changed:
print(f'- {item}')
-64
Просмотреть файл
@@ -1,64 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = '''
def bridge_exists(name: str) -> bool:
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
return False
return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]
'''
if 'def bridge_exists(name: str) -> bool:' not in text:
markers = [
'\n\ndef default_network_mode() -> str:',
'\n\ndef valid_vm_name(name: str) -> bool:',
'\n\ndef parse_virsh_list() -> list[dict[str, str]]:',
]
inserted = False
for marker in markers:
if marker in text:
text = text.replace(marker, helper + marker, 1)
changed.append('bridge_exists helper added')
inserted = True
break
if not inserted:
print('WARN: bridge helper marker not found, skip helper injection')
changed.append('bridge_exists helper skipped')
else:
changed.append('bridge_exists helper already present')
old_validation = ''' elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
error = "Некорректное имя bridge."
else:
iso = Path(iso_path).resolve()
'''
new_validation = ''' elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
error = "Некорректное имя bridge."
elif network_mode == "bridge" and 'bridge_exists' in globals() and not bridge_exists(bridge):
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
else:
iso = Path(iso_path).resolve()
'''
if old_validation in text:
text = text.replace(old_validation, new_validation, 1)
changed.append('bridge existence validation added')
elif 'Bridge {bridge} не найден на сервере' in text:
changed.append('bridge existence validation already present')
else:
print('WARN: bridge validation marker not found, skip validation injection')
changed.append('bridge validation skipped')
app_path.write_text(text)
print('vm network guard patch applied:')
for item in changed:
print(f'- {item}')
-101
Просмотреть файл
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
safe_proxy = '''# Virtuality noVNC console patch
async def proxy_vnc_to_websocket(reader: asyncio.StreamReader, websocket: WebSocket) -> None:
while True:
data = await reader.read(65536)
if not data:
break
try:
await websocket.send_bytes(data)
except (WebSocketDisconnect, RuntimeError, ConnectionError):
break
async def proxy_websocket_to_vnc(websocket: WebSocket, writer: asyncio.StreamWriter) -> None:
while True:
try:
message = await websocket.receive()
except (WebSocketDisconnect, RuntimeError, ConnectionError):
break
if message.get("type") == "websocket.disconnect":
break
if message.get("bytes") is not None:
writer.write(message["bytes"])
elif message.get("text") is not None:
writer.write(message["text"].encode())
try:
await writer.drain()
except (RuntimeError, ConnectionError, BrokenPipeError):
break
'''
safe_gather = ''' tasks = [
asyncio.create_task(proxy_vnc_to_websocket(reader, websocket)),
asyncio.create_task(proxy_websocket_to_vnc(websocket, writer)),
]
try:
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
await asyncio.gather(*done, return_exceptions=True)
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
'''
if 'Virtuality noVNC console patch' in text:
start = text.find('# Virtuality noVNC console patch')
marker = '\n\n@app.get("/login", response_class=HTMLResponse)'
end = text.find(marker, start)
if start != -1 and end != -1:
text = text[:start] + safe_proxy + text[end:]
old_gather = ''' try:
await asyncio.gather(proxy_vnc_to_websocket(reader, websocket), proxy_websocket_to_vnc(websocket, writer))
except (WebSocketDisconnect, asyncio.CancelledError, ConnectionError):
pass
finally:
writer.close()
await writer.wait_closed()
'''
if old_gather in text:
text = text.replace(old_gather, safe_gather, 1)
app_path.write_text(text)
print(f'noVNC console patch upgraded: {app_path}')
raise SystemExit(0)
text = text.replace('import crypt\n', 'import asyncio\nimport crypt\n', 1)
text = text.replace('from fastapi import FastAPI, Request, Form, UploadFile, File\n', 'from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect\n', 1)
text = text.replace('serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")\n', 'serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")\nconsole_serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-console")\n', 1)
text = text.replace('DEFAULT_BRIDGE = "br0"\n', 'DEFAULT_BRIDGE = "br0"\nNOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)\n', 1)
text = text.replace('app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")\n', 'app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")\nif NOVNC_DIR:\n app.mount("/novnc", StaticFiles(directory=str(NOVNC_DIR)), name="novnc")\n', 1)
old_get_user = '''def get_current_user(request: Request) -> str | None:\n token = request.cookies.get("virtuality_session")\n if not token:\n return None\n try:\n data = serializer.loads(token)\n except BadSignature:\n return None\n return AUTH_USER if data.get("user") == AUTH_USER else None\n'''
new_get_user = '''def user_from_session_token(token: str | None) -> str | None:\n if not token:\n return None\n try:\n data = serializer.loads(token)\n except BadSignature:\n return None\n return AUTH_USER if data.get("user") == AUTH_USER else None\n\n\ndef get_current_user(request: Request) -> str | None:\n return user_from_session_token(request.cookies.get("virtuality_session"))\n'''
text = text.replace(old_get_user, new_get_user, 1)
insert_after_vm_ip = '''def vm_vnc_display(name: str) -> str:\n return run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available"\n\n\ndef vnc_display_to_port(display: str) -> int | None:\n value = (display or "").strip()\n if not value or value == "not available":\n return None\n match = re.search(r":(\\d+)$", value)\n if not match:\n return None\n display_number = int(match.group(1))\n if display_number >= 5900:\n return display_number\n return 5900 + display_number\n\n\ndef console_info(name: str) -> dict[str, Any]:\n display = vm_vnc_display(name)\n port = vnc_display_to_port(display)\n has_novnc = bool(NOVNC_DIR and (NOVNC_DIR / "vnc.html").exists())\n token = None\n url = None\n if port and has_novnc:\n token = console_serializer.dumps({"vm": name, "port": port})\n url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=console/ws/{token}"\n return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url}\n\n\n'''
text = text.replace('def vm_details(name: str) -> dict[str, Any]:\n', insert_after_vm_ip + 'def vm_details(name: str) -> dict[str, Any]:\n', 1)
text = text.replace('"vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",', '"vnc": vm_vnc_display(name),', 1)
text = text.replace('@app.get("/login", response_class=HTMLResponse)\n', safe_proxy + '\n@app.get("/login", response_class=HTMLResponse)\n', 1)
console_routes = '''\n\n@app.get("/vm/{name}/console", response_class=HTMLResponse)\ndef vm_console_page(request: Request, name: str):\n auth_redirect = require_auth(request)\n if auth_redirect:\n return auth_redirect\n if not valid_vm_name(name) or not vm_exists(name):\n return RedirectResponse(url="/", status_code=303)\n return templates.TemplateResponse("console.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})\n\n\n@app.websocket("/console/ws/{token}")\nasync def console_websocket(websocket: WebSocket, token: str):\n if user_from_session_token(websocket.cookies.get("virtuality_session")) != AUTH_USER:\n await websocket.close(code=1008)\n return\n try:\n payload = console_serializer.loads(token)\n vm_name = payload.get("vm")\n target_port = int(payload.get("port"))\n except Exception:\n await websocket.close(code=1008)\n return\n if not valid_vm_name(vm_name) or not vm_exists(vm_name) or target_port < 5900 or target_port > 5999:\n await websocket.close(code=1008)\n return\n await websocket.accept()\n try:\n reader, writer = await asyncio.open_connection("127.0.0.1", target_port)\n except Exception:\n await websocket.close(code=1011)\n return\n''' + safe_gather.replace('\n', '\n')
text = text.replace('\n\n@app.get("/vm/{name}", response_class=HTMLResponse)\n', console_routes + '\n\n@app.get("/vm/{name}", response_class=HTMLResponse)\n', 1)
app_path.write_text(text)
print(f'noVNC console patch applied: {app_path}')