Установщик Windows узнаётся по метке ISO и имени файла: форма создания машины сама выбирает тип Windows, иначе диск был virtio и установщик его не видел

Этот коммит содержится в:
2026-09-27 21:24:19 +09:00
родитель ff3b09aba4
Коммит 5d02cc81ff
6 изменённых файлов: 92 добавлений и 5 удалений
+1 -1
Просмотреть файл
@@ -102,7 +102,7 @@ def test_dashboard_uses_plain_language(logged_in):
def test_vm_create_preselects_image(logged_in, data_dirs): def test_vm_create_preselects_image(logged_in, data_dirs):
(data_dirs["iso"] / "debian.iso").write_bytes(b"iso") (data_dirs["iso"] / "debian.iso").write_bytes(b"iso")
html = logged_in.get("/vm/create?iso=debian.iso").text html = logged_in.get("/vm/create?iso=debian.iso").text
assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" selected' in html assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" data-os="" selected' in html
def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch): def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch):
+37
Просмотреть файл
@@ -1,7 +1,10 @@
"""Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses.""" """Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses."""
import shlex import shlex
import pytest
import app import app
import catalog
import cloudinit import cloudinit
import conftest import conftest
import network_core import network_core
@@ -153,3 +156,37 @@ def test_vm_delete_releases_address(logged_in, data_dirs, monkeypatch):
net_calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))]) net_calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
assert logged_in.post("/vm/web01/delete", follow_redirects=False).status_code == 303 assert logged_in.post("/vm/web01/delete", follow_redirects=False).status_code == 303
assert find(calls, "virsh", "undefine") and find(net_calls, "virsh", "net-update", "virtuality-nat", "delete") assert find(calls, "virsh", "undefine") and find(net_calls, "virsh", "net-update", "virtuality-nat", "delete")
# ---------------------------------------------------------------- тип системы по установочному ISO
def fake_iso(path, label):
"""Минимальный ISO 9660: первичный дескриптор тома с меткой по смещению 0x8028."""
descriptor = bytearray(0x800)
descriptor[0], descriptor[1:6], descriptor[6] = 1, b"CD001", 1
descriptor[0x28:0x48] = label.encode().ljust(32)
path.write_bytes(bytes(0x8000) + bytes(descriptor))
return path
@pytest.mark.parametrize("label, name, expected", [
("19044_4046___WIN10_IOT_LTSC", "19044.4046---Win10-IoT-LTSC.iso", "windows10"),
("CCCOMA_X64FRE_RU-RU_DV9", "Win11_24H2_Russian_x64.iso", "windows11"),
("CCCOMA_X64FRE_EN-US_DV9", "windows.iso", "windows11"), # версия неизвестна: UEFI + TPM подходят и для 10
("SSS_X64FREE_EN-US_DV9", "20348.169.210806-2348.fe_release_SERVER_EVAL_x64FRE_en-us.iso", "windows10"),
("SSS_X64FREV_EN-US_DV9", "26100.1742.240906-0331.ge_release_SERVER_EVAL_x64FRE_en-us.iso", "windows11"),
("Ubuntu-Server 26.04 LTS amd64", "ubuntu-26.04-live-server-amd64.iso", ""),
("", "debian-13.1.0-amd64-netinst.iso", ""),
("smk-server", "smk-server-_1.iso", ""),
])
def test_guess_os_type_from_iso(label, name, expected):
assert catalog.guess_os_type(label, name) == expected
def test_windows_iso_preselects_windows_type(logged_in, data_dirs):
"""Машина с Windows 10 создалась как Linux: диск virtio, установщик Windows его не видел."""
fake_iso(data_dirs["iso"] / "19044.4046---Win10-IoT-LTSC.iso", "19044_4046___WIN10_IOT_LTSC")
fake_iso(data_dirs["iso"] / "ubuntu.iso", "Ubuntu-Server 26.04 LTS amd64")
assert app.iso_volume_label(data_dirs["iso"] / "ubuntu.iso") == "Ubuntu-Server 26.04 LTS amd64"
html = logged_in.get("/vm/create?iso=19044.4046---Win10-IoT-LTSC.iso").text
assert 'data-os="windows10" selected' in html and 'data-label="ubuntu.iso" data-os=""' in html
assert 'value="windows10" checked' in html and 'value="linux" checked' not in html
+15 -1
Просмотреть файл
@@ -553,6 +553,19 @@ def parse_pool_list() -> list[dict[str, str]]:
return rows return rows
def iso_volume_label(path: Path) -> str:
"""Метка тома ISO 9660: 32 байта в первичном дескрипторе по смещению 0x8028."""
try:
with path.open("rb") as handle:
handle.seek(0x8000)
descriptor = handle.read(0x48)
except OSError:
return ""
if descriptor[1:6] != b"CD001":
return ""
return descriptor[0x28:0x48].decode("ascii", "replace").strip()
def list_iso_files() -> list[dict[str, str]]: def list_iso_files() -> list[dict[str, str]]:
ISO_DIR.mkdir(parents=True, exist_ok=True) ISO_DIR.mkdir(parents=True, exist_ok=True)
files = [] files = []
@@ -564,7 +577,7 @@ def list_iso_files() -> list[dict[str, str]]:
except OSError: except OSError:
size_mb = 0 size_mb = 0
updated = "unknown" updated = "unknown"
files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated}) files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated, "os_type": catalog.guess_os_type(iso_volume_label(item), item.name)})
return files return files
@@ -1819,6 +1832,7 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""):
form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE} form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE}
if iso: if iso:
form["iso_path"] = str(ISO_DIR / Path(iso).name) form["iso_path"] = str(ISO_DIR / Path(iso).name)
form["os_type"] = catalog.guess_os_type(iso_volume_label(ISO_DIR / Path(iso).name), Path(iso).name) or "linux"
if image: if image:
form["disk_image_path"] = str(DISK_IMAGES_DIR / Path(image).name) form["disk_image_path"] = str(DISK_IMAGES_DIR / Path(image).name)
return vm_form_context(request, form=form) return vm_form_context(request, form=form)
+19
Просмотреть файл
@@ -3,6 +3,7 @@
Each entry points to the vendor's official cloud image. Images boot in seconds Each entry points to the vendor's official cloud image. Images boot in seconds
and are configured through cloud-init (user, password, SSH key) — no installer. and are configured through cloud-init (user, password, SSH key) — no installer.
""" """
import re
from typing import Any from typing import Any
# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13. # osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13.
@@ -69,6 +70,24 @@ OS_TYPES: list[dict[str, Any]] = [
] ]
# Установщик Windows узнаём по метке тома ISO (у Microsoft вида CCCOMA_X64FRE_RU-RU_DV9) или по имени файла.
WINDOWS_RE = re.compile(r"\bWIN(?:DOWS)?\s?(?:1[01]|7|8|SERVER)\b|\bWINDOWS\b|\b(?:X64|X86|A64|ARM64)FRE[EV]?\b")
WINDOWS11_RE = re.compile(r"\bWIN(?:DOWS)?\s?11\b|\bSERVER\s?2025\b|\b(?:22000|22621|22631|26100|26200)\b")
WINDOWS10_RE = re.compile(r"\bWIN(?:DOWS)?\s?(?:10|7|8)\b|\bLTSC\b|\bSERVER\s?20(?:16|19|22)\b|\b1\d{4}\b|\b20348\b")
def guess_os_type(volume_label: str, filename: str) -> str:
"""Тип системы для установочного ISO: windows11 / windows10, если это установщик Windows, иначе "".
Windows неизвестной версии — windows11: UEFI и TPM подходят и для Windows 10, а без них не встанет 11."""
text = re.sub(r"[^A-Z0-9]+", " ", f"{volume_label} {filename}".upper())
if not WINDOWS_RE.search(text):
return ""
if WINDOWS11_RE.search(text):
return "windows11"
return "windows10" if WINDOWS10_RE.search(text) else "windows11"
def cloud_image(image_id: str) -> dict[str, Any] | None: def cloud_image(image_id: str) -> dict[str, Any] | None:
return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None) return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None)
+17 -1
Просмотреть файл
@@ -478,8 +478,24 @@
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); }); $$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
$$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; }); $$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; });
} }
// Установщик Windows панель узнаёт по ISO (data-os) и сама выбирает тип, пока пользователь не выбрал его руками.
const guessOs = () => {
const select = field('iso_path');
const option = select && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
const guess = option?.dataset.os || '';
const hint = $('[data-os-hint]', form);
if (form.dataset.osManual) return;
const radio = $(`input[name=os_type][value="${guess || 'linux'}"]`, form);
if (radio) radio.checked = true;
if (hint) hint.hidden = !guess;
};
form.addEventListener('input', (event) => { if (event.target.name === 'ci_user') event.target.dataset.autoLogin = event.target.value ? '0' : '1'; sync(); }); form.addEventListener('input', (event) => { if (event.target.name === 'ci_user') event.target.dataset.autoLogin = event.target.value ? '0' : '1'; sync(); });
form.addEventListener('change', sync); form.addEventListener('change', (event) => {
if (event.target.name === 'os_type') { form.dataset.osManual = '1'; const hint = $('[data-os-hint]', form); if (hint) hint.hidden = true; }
if (event.target.name === 'iso_path') guessOs();
sync();
});
guessOs();
sync(); sync();
} }
+3 -2
Просмотреть файл
@@ -16,7 +16,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<form method="post" action="/vm/create" class="grid grid-main" data-wizard data-has-isos="{{ '1' if isos else '0' }}" data-has-disks="{{ '1' if disk_images else '0' }}"> <form method="post" action="/vm/create" class="grid grid-main" data-wizard {% if form.name %}data-os-manual="1"{% endif %} data-has-isos="{{ '1' if isos else '0' }}" data-has-disks="{{ '1' if disk_images else '0' }}">
<div class="stack"> <div class="stack">
<article class="card step-card"> <article class="card step-card">
<div class="step-head"><span class="step-num">1</span><div><h2>Название и система</h2><p>Как назовём машину и что на неё установить</p></div></div> <div class="step-head"><span class="step-num">1</span><div><h2>Название и система</h2><p>Как назовём машину и что на неё установить</p></div></div>
@@ -50,7 +50,7 @@
<label for="iso_path">Установочный образ</label> <label for="iso_path">Установочный образ</label>
{% if isos %} {% if isos %}
<select class="select" id="iso_path" name="iso_path"> <select class="select" id="iso_path" name="iso_path">
{% for iso in isos %}<option value="{{ iso.path }}" data-label="{{ iso.name }}" {% if form.iso_path == iso.path %}selected{% endif %}>{{ iso.name }} · {{ iso.size }}</option>{% endfor %} {% for iso in isos %}<option value="{{ iso.path }}" data-label="{{ iso.name }}" data-os="{{ iso.os_type }}" {% if form.iso_path == iso.path %}selected{% endif %}>{{ iso.name }} · {{ iso.size }}</option>{% endfor %}
</select> </select>
<span class="hint">Нужен другой? <a href="/iso">Загрузите образ</a>.</span> <span class="hint">Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
{% endif %} {% endif %}
@@ -65,6 +65,7 @@
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
<span class="hint" data-os-hint hidden>{{ icon('sparkles', 'icon-sm') }} Это установщик Windows, тип выбран сам. Если ошиблись, выберите другой.</span>
<span class="hint">От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM.</span> <span class="hint">От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM.</span>
</div> </div>
<div class="field" data-show-when="source_type=disk_image"> <div class="field" data-show-when="source_type=disk_image">