diff --git a/tests/conftest.py b/tests/conftest.py index 667dba2..af64845 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -171,6 +171,7 @@ def data_dirs(tmp_path, monkeypatch): monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json") monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log") 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()): monkeypatch.setattr(module, "run_cmd", fake_run_cmd) return dirs diff --git a/tests/test_app.py b/tests/test_app.py index 480691b..a2436ec 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -196,3 +196,27 @@ def test_uploader_collects_file_before_disabling_input(): assert "input.disabled = active" in uploader assert uploader.index("new FormData(form)") < uploader.index("setActive(true)") 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] diff --git a/web/app.py b/web/app.py index 5a3f225..99c971c 100644 --- a/web/app.py +++ b/web/app.py @@ -1273,10 +1273,20 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str: 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): profile = host_profile.load_host_profile() 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 @@ -1785,12 +1795,16 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""): @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) if auth_redirect: return auth_redirect 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 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 [] @@ -1804,6 +1818,8 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form error = "CPU должен быть от 1 до 128 vCPU." elif disk_size < 4 or disk_size > 4096: 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"): error = "Некорректный режим сети." 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)] 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] + 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 "") operation_id = str(uuid.uuid4()) diff --git a/web/templates/vm_create.html b/web/templates/vm_create.html index 4fbaf00..deef113 100644 --- a/web/templates/vm_create.html +++ b/web/templates/vm_create.html @@ -122,6 +122,13 @@
+Свободно на сервере: {{ images_free }}. Диск машины занимает место постепенно, по мере заполнения.
+ {% if form.overcommit %} + + {% endif %} {% if profile.virtualization_warning %}{{ alert('warning', 'На этом сервере нет аппаратного ускорения — машины будут заметно медленнее.') }}{% endif %}