Установщик Windows узнаётся по метке ISO и имени файла: форма создания машины сама выбирает тип Windows, иначе диск был virtio и установщик его не видел
Этот коммит содержится в:
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user