Создание машины: диск больше свободного места создаётся только с подтверждением, в шаге «Мощность» видно свободное место; раньше установка падала с ошибкой virt-install

Этот коммит содержится в:
2026-09-27 16:43:13 +09:00
родитель 2a4bffc7df
Коммит cc3abe3f4a
4 изменённых файлов: 53 добавлений и 3 удалений
+1
Просмотреть файл
@@ -171,6 +171,7 @@ def data_dirs(tmp_path, monkeypatch):
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json") monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log") monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT) monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
monkeypatch.setattr(app, "images_free_bytes", lambda: 500 * 1024 ** 3) # временная папка тестов бывает маленькой (tmpfs)
for module in (app, core, network_core, host_profile, *feature_modules()): for module in (app, core, network_core, host_profile, *feature_modules()):
monkeypatch.setattr(module, "run_cmd", fake_run_cmd) monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
return dirs return dirs
+24
Просмотреть файл
@@ -196,3 +196,27 @@ def test_uploader_collects_file_before_disabling_input():
assert "input.disabled = active" in uploader assert "input.disabled = active" in uploader
assert uploader.index("new FormData(form)") < uploader.index("setActive(true)") assert uploader.index("new FormData(form)") < uploader.index("setActive(true)")
assert "xhr.send(data)" in uploader assert "xhr.send(data)" in uploader
def test_vm_create_disk_bigger_than_free_space_needs_confirmation(logged_in, data_dirs, monkeypatch):
"""virt-install отказывал с английской ошибкой; теперь панель предупреждает сама и создаёт только с подтверждением."""
iso = data_dirs["iso"] / "smk.iso"
iso.write_bytes(b"iso")
started = []
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: started.append(cmd))
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
monkeypatch.setattr(app, "images_free_bytes", lambda: 25 * 1024 ** 3)
form = {"name": "smk", "memory": 4096, "vcpus": 2, "disk_size": 40, "iso_path": str(iso), "source_type": "iso", "network_mode": "nat"}
page = logged_in.get("/vm/create").text
assert "Свободно на сервере: 25" in page and 'name="allow_overcommit"' not in page
response = logged_in.post("/vm/create", data=form)
assert response.status_code == 400 and started == []
assert "Диск на 40 ГБ больше, чем свободно на сервере" in response.text and 'name="allow_overcommit"' in response.text
response = logged_in.post("/vm/create", data={**form, "allow_overcommit": "1"}, follow_redirects=False)
assert response.status_code == 303, response.text[:300]
assert started[0][-2:] == ["--check", "disk_size=off"]
response = logged_in.post("/vm/create", data={**form, "name": "small", "disk_size": 20}, follow_redirects=False)
assert response.status_code == 303 and "--check" not in started[1]
+21 -3
Просмотреть файл
@@ -1273,10 +1273,20 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
return value return value
def images_free_bytes() -> int:
"""Свободное место там, где лежат диски машин."""
for path in (IMAGES_DIR, STORAGE_DIR, Path("/")):
try:
return shutil.disk_usage(path).free
except OSError:
continue
return 0
def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200): def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200):
profile = host_profile.load_host_profile() profile = host_profile.load_host_profile()
default_mode = profile.get("recommended_network", "nat") default_mode = profile.get("recommended_network", "nat")
return templates.TemplateResponse(request, "vm_create.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "os_types": catalog.OS_TYPES, "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code) return templates.TemplateResponse(request, "vm_create.html", {"app_name": APP_NAME, "user": AUTH_USER, "images_free": presenters.format_bytes(images_free_bytes()), "isos": list_iso_files(), "disk_images": list_disk_image_files(), "os_types": catalog.OS_TYPES, "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
# Virtuality noVNC console patch # Virtuality noVNC console patch
@@ -1785,12 +1795,16 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""):
@app.post("/vm/create", response_class=HTMLResponse) @app.post("/vm/create", response_class=HTMLResponse)
def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(20), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE), os_type: str = Form("linux"), cloud_init: str = Form("0"), ci_user: str = Form(""), ci_password: str = Form(""), ci_ssh_key: str = Form("")): def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(20), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE), os_type: str = Form("linux"), cloud_init: str = Form("0"), ci_user: str = Form(""), ci_password: str = Form(""), ci_ssh_key: str = Form(""), allow_overcommit: str = Form("")):
auth_redirect = require_auth(request) auth_redirect = require_auth(request)
if auth_redirect: if auth_redirect:
return auth_redirect return auth_redirect
ci_user = ci_user.strip() ci_user = ci_user.strip()
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, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge, "os_type": os_type, "cloud_init": cloud_init, "ci_user": ci_user, "ci_ssh_key": ci_ssh_key} # Диск qcow2 занимает место по мере заполнения, поэтому больше свободного места его создать можно,
# но только осознанно: если машина его заполнит, место на сервере кончится у всех машин.
free = images_free_bytes()
too_big = disk_size * 1024 ** 3 > free
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, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge, "os_type": os_type, "cloud_init": cloud_init, "ci_user": ci_user, "ci_ssh_key": ci_ssh_key, "overcommit": too_big, "allow_overcommit": allow_overcommit == "1"}
error = None error = None
use_cloud_init = source_type == "disk_image" and cloud_init == "1" use_cloud_init = source_type == "disk_image" and cloud_init == "1"
ssh_keys = cloudinit.parse_ssh_keys(ci_ssh_key) if use_cloud_init else [] ssh_keys = cloudinit.parse_ssh_keys(ci_ssh_key) if use_cloud_init else []
@@ -1804,6 +1818,8 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
error = "CPU должен быть от 1 до 128 vCPU." error = "CPU должен быть от 1 до 128 vCPU."
elif disk_size < 4 or disk_size > 4096: elif disk_size < 4 or disk_size > 4096:
error = "Диск должен быть от 4 GB до 4096 GB." error = "Диск должен быть от 4 GB до 4096 GB."
elif too_big and allow_overcommit != "1":
error = f"Диск на {disk_size} ГБ больше, чем свободно на сервере ({presenters.format_bytes(free)}). Уменьшите диск или подтвердите создание в шаге «Мощность»."
elif network_mode not in ("nat", "bridge"): elif network_mode not in ("nat", "bridge"):
error = "Некорректный режим сети." error = "Некорректный режим сети."
elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)): elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
@@ -1903,6 +1919,8 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)] cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)]
else: else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics] cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics]
if too_big:
cmd += ["--check", "disk_size=off"] # подтверждено пользователем: virt-install иначе откажется
success_message = "Машина создана" + (f". {login_hint}" if login_hint else "") + (f". Адрес в сети машин: {nat_ip}" if nat_ip else "") success_message = "Машина создана" + (f". {login_hint}" if login_hint else "") + (f". Адрес в сети машин: {nat_ip}" if nat_ip else "")
operation_id = str(uuid.uuid4()) operation_id = str(uuid.uuid4())
+7
Просмотреть файл
@@ -122,6 +122,13 @@
<div class="field"><label for="memory">Память, МБ</label><input class="input" id="memory" type="number" name="memory" min="512" max="262144" step="256" value="{{ form.memory }}" required></div> <div class="field"><label for="memory">Память, МБ</label><input class="input" id="memory" type="number" name="memory" min="512" max="262144" step="256" value="{{ form.memory }}" required></div>
<div class="field"><label for="disk_size">Диск, ГБ</label><input class="input" id="disk_size" type="number" name="disk_size" min="4" max="4096" value="{{ form.disk_size }}" required></div> <div class="field"><label for="disk_size">Диск, ГБ</label><input class="input" id="disk_size" type="number" name="disk_size" min="4" max="4096" value="{{ form.disk_size }}" required></div>
</div> </div>
<p class="subtle small">Свободно на сервере: {{ images_free }}. Диск машины занимает место постепенно, по мере заполнения.</p>
{% if form.overcommit %}
<label class="check danger">
<input type="checkbox" name="allow_overcommit" value="1" {% if form.allow_overcommit %}checked{% endif %}>
<span><strong>Всё равно создать диск на {{ form.disk_size }} ГБ</strong><span>На сервере свободно {{ images_free }}. Сразу столько места диск не займёт, поэтому создать его можно. Но если система внутри машины заполнит больше, чем есть на сервере, место закончится у всех машин и они остановятся. Надёжнее уменьшить диск или освободить место.</span></span>
</label>
{% endif %}
{% if profile.virtualization_warning %}{{ alert('warning', 'На этом сервере нет аппаратного ускорения — машины будут заметно медленнее.') }}{% endif %} {% if profile.virtualization_warning %}{{ alert('warning', 'На этом сервере нет аппаратного ускорения — машины будут заметно медленнее.') }}{% endif %}
</div> </div>
</article> </article>