From 4fd04be5888532bcd9211f4db3fa2cfd53850da8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:49:43 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BA=D0=B0=D1=87=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B5=20=D0=B8?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B0=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D1=8B=D1=85=20=D1=81?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B5=D0=BC=20=D0=B1=D1=8B=D0=BB=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/features/downloads.py: фоновая операция типа «download» — urllib по 1 МиБ, таймаут 30 с, User-Agent Virtuality/<версия>, запись в .<имя>.part и переименование в конце; в сообщении скорость и остаток («245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин»); отмена между кусками (POST /api/operations/{id}/cancel и /downloads/{id}/cancel); проверка схемы, имени файла, занятости имени и свободного места (Content-Length + 512 МиБ); для каталога — проверка контрольной суммы (SHA256SUMS/SHA512SUMS/одиночный хеш), при несовпадении файл удаляется; после ISO — virsh pool-refresh virtuality-iso, после .img.xz — распаковка и запуск конвертации в qcow2 как при загрузке с компьютера. - /iso и /disk-images: карточка «Скачать по ссылке», на /disk-images — «Каталог готовых систем» для процессора сервера с пометками «Рекомендуем», «Загружен», «Скачивается»; идущие скачивания вверху страниц с живым прогрессом (опрос раз в 2 с) и «Отменить». - Тесты: парсер контрольных сумм, проверка ссылок и имён, загрузка с локального http-сервера, редирект, отмена, несовпадение суммы, нехватка места, .img.xz, маршруты. - В ветку скопированы web/core.py, web/catalog.py, presenters.format_bytes и tests/fixtures из общей рабочей копии (без изменений), conftest подменяет run_cmd и каталоги и в core. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/conftest.py | 22 +- tests/dev_server.py | 15 +- tests/fixtures/SHA256SUMS | 3 + tests/fixtures/SHA512SUMS | 3 + tests/fixtures/domblkinfo.txt | 3 + tests/fixtures/domblklist-details.txt | 4 + tests/fixtures/domstats.txt | 16 + tests/fixtures/dumpxml-web01.xml | 37 ++ tests/fixtures/net-dumpxml.xml | 17 + ...23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 | 1 + tests/fixtures/qemu-img-info.json | 10 + tests/fixtures/snapshot-info.txt | 9 + tests/fixtures/snapshot-list.txt | 4 + tests/test_downloads.py | 381 ++++++++++++ web/app.py | 4 + web/catalog.py | 82 +++ web/core.py | 321 ++++++++++ web/features/__init__.py | 0 web/features/downloads.py | 578 ++++++++++++++++++ web/presenters.py | 9 + web/static/app.css | 9 + web/templates/_downloads_active.html | 71 +++ web/templates/_downloads_catalog.html | 36 ++ web/templates/_downloads_flash.html | 4 + web/templates/_downloads_url.html | 17 + web/templates/disk_images.html | 9 +- web/templates/iso.html | 4 + 27 files changed, 1662 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/SHA256SUMS create mode 100644 tests/fixtures/SHA512SUMS create mode 100644 tests/fixtures/domblkinfo.txt create mode 100644 tests/fixtures/domblklist-details.txt create mode 100644 tests/fixtures/domstats.txt create mode 100644 tests/fixtures/dumpxml-web01.xml create mode 100644 tests/fixtures/net-dumpxml.xml create mode 100644 tests/fixtures/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 create mode 100644 tests/fixtures/qemu-img-info.json create mode 100644 tests/fixtures/snapshot-info.txt create mode 100644 tests/fixtures/snapshot-list.txt create mode 100644 tests/test_downloads.py create mode 100644 web/catalog.py create mode 100644 web/core.py create mode 100644 web/features/__init__.py create mode 100644 web/features/downloads.py create mode 100644 web/templates/_downloads_active.html create mode 100644 web/templates/_downloads_catalog.html create mode 100644 web/templates/_downloads_flash.html create mode 100644 web/templates/_downloads_url.html diff --git a/tests/conftest.py b/tests/conftest.py index 0491550..0a6b806 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,6 +57,8 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs): return fake_result(":0") if cmd[:2] == ["virsh", "pool-list"]: return fake_result(" Name State Autostart\n---\n virtuality-images active yes") + if cmd[:2] == ["virsh", "pool-refresh"]: + return fake_result(f"Pool {cmd[-1]} refreshed") if cmd[:2] == ["virsh", "net-list"]: return fake_result(" Name State Autostart Persistent\n---\n virtuality-nat active yes yes") if cmd[:2] == ["systemctl", "is-active"]: @@ -72,9 +74,15 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs): return fake_result() +def feature_modules(): + """Загруженные модули web/features/* — им тоже подменяем run_cmd.""" + return [module for name, module in sys.modules.items() if name.startswith("features.") and hasattr(module, "run_cmd")] + + @pytest.fixture() def data_dirs(tmp_path, monkeypatch): import app + import core import host_profile import network_core import update_core @@ -84,6 +92,7 @@ def data_dirs(tmp_path, monkeypatch): "images": tmp_path / "images", "disk_images": tmp_path / "disk-images", "operations": tmp_path / "operations", + "backups": tmp_path / "backups", "network": tmp_path / "network", "config": tmp_path / "config", "nft": tmp_path / "nft", @@ -91,10 +100,13 @@ def data_dirs(tmp_path, monkeypatch): } for path in dirs.values(): path.mkdir() - monkeypatch.setattr(app, "ISO_DIR", dirs["iso"]) - monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"]) - monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"]) - monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"]) + for module in (app, core): + monkeypatch.setattr(module, "ISO_DIR", dirs["iso"]) + monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"]) + monkeypatch.setattr(module, "DISK_IMAGES_DIR", dirs["disk_images"]) + monkeypatch.setattr(module, "OPERATIONS_DIR", dirs["operations"]) + monkeypatch.setattr(core, "BACKUPS_DIR", dirs["backups"]) + monkeypatch.setattr(core, "CONFIG_DIR", dirs["config"]) monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"]) monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"]) monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"]) @@ -108,7 +120,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) - for module in (app, network_core, host_profile): + 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/dev_server.py b/tests/dev_server.py index 9df2e62..6de1733 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -13,6 +13,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import conftest # noqa: E402 (sets sys.path and env) import app # noqa: E402 +import core # noqa: E402 import host_profile # noqa: E402 import network_core # noqa: E402 import update_core # noqa: E402 @@ -23,6 +24,7 @@ def seed(root: Path) -> None: for path in dirs.values(): path.mkdir(parents=True, exist_ok=True) app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"] + core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR, core.CONFIG_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"], dirs["config"] network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"] network_core.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json" network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml" @@ -32,7 +34,7 @@ def seed(root: Path) -> None: host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json" update_core.STATE_DIR, update_core.STATE_FILE, update_core.LOG_FILE = dirs["update"], dirs["update"] / "state.json", dirs["update"] / "update.log" update_core.SOURCE_DIR = Path(__file__).resolve().parents[1] - for module in (app, network_core, host_profile): + for module in (app, core, network_core, host_profile, *conftest.feature_modules()): module.run_cmd = conftest.fake_run_cmd app.auth.verify_password = lambda user, password: True @@ -50,6 +52,17 @@ def seed(root: Path) -> None: app.write_operation({"id": op_id, "type": "vm_create", "title": title, "status": status, "progress": progress, "message": "Готово" if status == "success" else "qemu-img: 64%" if status == "running" else "virt-install завершился с ошибкой: 1", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester"}) app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...") + # Идущие скачивания — видны на /iso и /disk-images (в каталоге Ubuntu 26.04 помечена «Скачивается»). + for kind, url, catalog_id, title, progress, message, total, done in ( + ("iso", "https://software-download.microsoft.com/download/pr/windows-server-2025-eval-x64.iso", None, "Скачивание windows-server-2025-eval-x64.iso", 37, "1,9 ГБ из 5,2 ГБ · 24 МБ/с · осталось 2 мин", 5_583_457_280, 2_065_879_193), + ("disk", "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "ubuntu-26.04", "Скачивание Ubuntu Server 26.04 LTS", 62, "412 МБ из 663 МБ · 31 МБ/с · осталось 8 с", 695_205_888, 431_027_650), + ): + filename = url.rsplit("/", 1)[-1] + target = (dirs["iso"] if kind == "iso" else dirs["disk-images"]) / filename + op = core.new_operation("download", title, download_kind=kind, url=url, filename=filename, target_path=str(target), page="/iso" if kind == "iso" else "/disk-images", catalog_id=catalog_id, total_bytes=total, downloaded_bytes=done, cancel_requested=False) + core.update_operation(op, status="running", progress=progress, message=message, started_at=core.utc_now()) + core.append_operation_log(op["id"], f"Ссылка: {url}") + if __name__ == "__main__": import uvicorn diff --git a/tests/fixtures/SHA256SUMS b/tests/fixtures/SHA256SUMS new file mode 100644 index 0000000..ef21e26 --- /dev/null +++ b/tests/fixtures/SHA256SUMS @@ -0,0 +1,3 @@ +5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51 *resolute-server-cloudimg-amd64.img +f69162950f235e3cdbbad33f1f912d1a504be90d8a37d002c735d6f3e3882265 *resolute-server-cloudimg-arm64.img +e0361263a9f568c21a59e75c3b7810bc5867866e5bd48f499f8b2e6ab723f059 *resolute-server-cloudimg-amd64.vmdk diff --git a/tests/fixtures/SHA512SUMS b/tests/fixtures/SHA512SUMS new file mode 100644 index 0000000..0091726 --- /dev/null +++ b/tests/fixtures/SHA512SUMS @@ -0,0 +1,3 @@ +# generated by debian cloud team +1412d62c2d55ae2432df6d22c36b9edf4512a59a43bb4eadb1bd3a5ef201154a3052d83ffae704d7079b50388e61992013348e4bb4dee45b3c2a266e2b296135 debian-13-genericcloud-amd64.qcow2 +27c03aab0b5ee1f5918bfaa94821d2a1500fc171ffeb0ae159e7d18662f49171551f397683004434096e4006380fa607577b3c90fafa1eceb3a92702ebd49a15 debian-13-genericcloud-arm64.qcow2 diff --git a/tests/fixtures/domblkinfo.txt b/tests/fixtures/domblkinfo.txt new file mode 100644 index 0000000..075a7e8 --- /dev/null +++ b/tests/fixtures/domblkinfo.txt @@ -0,0 +1,3 @@ +Capacity: 42949672960 +Allocation: 6442450944 +Physical: 6443499520 diff --git a/tests/fixtures/domblklist-details.txt b/tests/fixtures/domblklist-details.txt new file mode 100644 index 0000000..8300047 --- /dev/null +++ b/tests/fixtures/domblklist-details.txt @@ -0,0 +1,4 @@ + Type Device Target Source +------------------------------------------------------------ + file disk vda /var/lib/virtuality/images/web01.qcow2 + file cdrom sda - diff --git a/tests/fixtures/domstats.txt b/tests/fixtures/domstats.txt new file mode 100644 index 0000000..5eb2d80 --- /dev/null +++ b/tests/fixtures/domstats.txt @@ -0,0 +1,16 @@ +Domain: 'web01' + cpu.time=48123456789 + cpu.user=30000000000 + cpu.system=18123456789 + balloon.current=2097152 + balloon.maximum=2097152 + balloon.rss=1534208 + vcpu.current=2 + vcpu.maximum=2 + vcpu.0.state=1 + vcpu.0.time=24000000000 + vcpu.0.wait=0 + vcpu.1.state=1 + vcpu.1.time=24123456789 + vcpu.1.wait=0 + diff --git a/tests/fixtures/dumpxml-web01.xml b/tests/fixtures/dumpxml-web01.xml new file mode 100644 index 0000000..23387f9 --- /dev/null +++ b/tests/fixtures/dumpxml-web01.xml @@ -0,0 +1,37 @@ + + web01 + 6c1b3c1e-0000-0000-0000-000000000001 + Сайт компании + Nginx + база. Бэкап по пятницам. + 2097152 + 2097152 + 2 + + hvm + + + + /usr/bin/qemu-system-x86_64 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/net-dumpxml.xml b/tests/fixtures/net-dumpxml.xml new file mode 100644 index 0000000..cf4594a --- /dev/null +++ b/tests/fixtures/net-dumpxml.xml @@ -0,0 +1,17 @@ + + virtuality-nat + 6c1b3c1e-1111-2222-3333-444444444444 + + + + + + + + + + + + + + diff --git a/tests/fixtures/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 b/tests/fixtures/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 new file mode 100644 index 0000000..6274704 --- /dev/null +++ b/tests/fixtures/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 @@ -0,0 +1 @@ +2ae30006299896d225c9d4de36e9a7c3d047c9b4a5793efe3a6422ec5040e8ee5f7d71326aa9922ffa446fbd11ed6cd1a8d0af4659b3d00233d3fea3550d2d78 diff --git a/tests/fixtures/qemu-img-info.json b/tests/fixtures/qemu-img-info.json new file mode 100644 index 0000000..426201a --- /dev/null +++ b/tests/fixtures/qemu-img-info.json @@ -0,0 +1,10 @@ +{ + "children": [], + "virtual-size": 42949672960, + "filename": "/var/lib/virtuality/images/web01.qcow2", + "cluster-size": 65536, + "format": "qcow2", + "actual-size": 6443499520, + "format-specific": {"type": "qcow2", "data": {"compat": "1.1", "lazy-refcounts": false, "refcount-bits": 16, "corrupt": false}}, + "dirty-flag": false +} diff --git a/tests/fixtures/snapshot-info.txt b/tests/fixtures/snapshot-info.txt new file mode 100644 index 0000000..e5d500f --- /dev/null +++ b/tests/fixtures/snapshot-info.txt @@ -0,0 +1,9 @@ +Name: clean +Domain: web01 +Current: yes +State: running +Location: internal +Parent: - +Children: 1 +Descendants: 1 +Metadata: yes diff --git a/tests/fixtures/snapshot-list.txt b/tests/fixtures/snapshot-list.txt new file mode 100644 index 0000000..2ee1326 --- /dev/null +++ b/tests/fixtures/snapshot-list.txt @@ -0,0 +1,4 @@ + Name Creation Time State +------------------------------------------------------ + before-upd 2026-09-25 10:12:03 +0000 shutoff + clean 2026-09-24 09:00:41 +0000 running diff --git a/tests/test_downloads.py b/tests/test_downloads.py new file mode 100644 index 0000000..d2bff1b --- /dev/null +++ b/tests/test_downloads.py @@ -0,0 +1,381 @@ +"""Скачивание образов по ссылке и из каталога: парсер контрольных сумм, +проверка ссылок, фоновая загрузка с локального http-сервера, отмена.""" +import hashlib +import lzma +import threading +import time +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace +from urllib.parse import unquote + +import pytest + +import app # noqa: F401 — подключает features.downloads к приложению +import catalog +import core +import features.downloads as downloads + +FIXTURES = Path(__file__).resolve().parent / "fixtures" +MB = 1024 * 1024 +SLOW_TOTAL = 4 * MB + + +class Handler(SimpleHTTPRequestHandler): + """Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет.""" + + root = "" + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=self.root, **kwargs) + + def log_message(self, *args): # noqa: D102 — тише в тестах + pass + + def do_GET(self): + if self.path == "/redirect.iso": + self.send_response(302) + self.send_header("Location", "/small.iso") + self.end_headers() + return + if self.path == "/slow.iso": + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(SLOW_TOTAL)) + self.end_headers() + try: + for _ in range(SLOW_TOTAL // (64 * 1024)): + self.wfile.write(b"\0" * 64 * 1024) + self.wfile.flush() + time.sleep(0.05) + except (BrokenPipeError, ConnectionResetError): + pass + return + super().do_GET() + + +@pytest.fixture() +def www(tmp_path): + root = tmp_path / "www" + root.mkdir() + (root / "small.iso").write_bytes(b"ISO" * 5000) + handler = type("TestHandler", (Handler,), {"root": str(root)}) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield SimpleNamespace(root=root, url=f"http://127.0.0.1:{server.server_port}") + server.shutdown() + server.server_close() + + +def wait_for(operation_id: str, timeout: float = 20.0) -> dict: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + operation = core.read_operation(operation_id) + if operation and operation.get("status") in ("success", "error"): + return operation + time.sleep(0.05) + raise AssertionError("операция не завершилась вовремя") + + +def wait_until(predicate, timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.05) + raise AssertionError("условие не наступило вовремя") + + +# ---------------------------------------------------------------- parsers & formatting +def test_parse_checksums_ubuntu_star_format(): + text = (FIXTURES / "SHA256SUMS").read_text() + assert downloads.parse_checksums(text, "resolute-server-cloudimg-amd64.img") == "5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51" + assert downloads.parse_checksums(text, "resolute-server-cloudimg-arm64.img").startswith("f6916295") + assert downloads.parse_checksums(text, "missing.img") is None + + +def test_parse_checksums_debian_two_spaces_and_comments(): + text = (FIXTURES / "SHA512SUMS").read_text() + assert downloads.parse_checksums(text, "debian-13-genericcloud-amd64.qcow2").startswith("1412d62c2d55") + assert downloads.parse_checksums(text, "debian-13-genericcloud-arm64.qcow2").startswith("27c03aab0b5e") + + +def test_parse_checksums_alpine_bare_hash_and_bsd_style(): + bare = (FIXTURES / "nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512").read_text() + assert downloads.parse_checksums(bare, "anything.qcow2") == bare.strip().lower() + bsd = "SHA256 (disk.qcow2) = ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789\n" + assert downloads.parse_checksums(bsd, "disk.qcow2") == "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + assert downloads.parse_checksums(bsd, "other.qcow2") is None + assert downloads.parse_checksums("not a checksum file\n", "disk.qcow2") is None + + +def test_human_size_and_eta(): + assert downloads.human_size(0) == "0 Б" + assert downloads.human_size(1023) == "1023 Б" + assert downloads.human_size(1536) == "1,5 КБ" + assert downloads.human_size(245 * MB) == "245 МБ" + assert downloads.human_size(int(3.1 * 1024 * MB)) == "3,1 ГБ" + assert downloads.human_size(10 * MB) == "10 МБ" + assert downloads.human_eta(45) == "45 с" + assert downloads.human_eta(244) == "4 мин" + assert downloads.human_eta(3700) == "1 ч 2 мин" + assert downloads.human_eta(7200) == "2 ч" + + +def test_progress_message(): + assert downloads.progress_message(245 * MB, int(3.1 * 1024 * MB), 12 * MB) == "245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин" + assert downloads.progress_message(245 * MB, None, 12 * MB) == "245 МБ · 12 МБ/с" + assert downloads.progress_message(0, 100 * MB, None) == "0 Б из 100 МБ" + + +# ---------------------------------------------------------------- validation +@pytest.mark.parametrize("url", ["", "ftp://example.org/x.iso", "file:///etc/passwd", "javascript:alert(1)", "http://", "http:///x.iso", "http://example.org/a b.iso"]) +def test_validate_url_rejects(url): + with pytest.raises(downloads.DownloadError): + downloads.validate_url(url) + + +def test_filename_from_url_sanitises_and_checks_suffix(): + assert downloads.filename_from_url("https://h.example/a%20b%20(1).ISO?x=1#frag", "iso") == "a-b-_1.iso" + assert downloads.filename_from_url("https://h.example/dir/debian-13.qcow2", "disk") == "debian-13.qcow2" + assert downloads.filename_from_url("https://h.example/rpi/2026-01-01-raspios.img.xz", "disk") == "2026-01-01-raspios.img.xz" + assert downloads.filename_from_url("https://h.example/../../etc.iso", "iso") == "etc.iso" + for url, kind in (("https://h.example/", "iso"), ("https://h.example/setup.exe", "iso"), ("https://h.example/x.iso", "disk"), ("https://h.example/x.qcow2", "iso")): + with pytest.raises(downloads.DownloadError): + downloads.filename_from_url(url, kind) + + +def test_start_download_refuses_existing_file(data_dirs): + (data_dirs["iso"] / "small.iso").write_bytes(b"x") + with pytest.raises(downloads.DownloadError, match="уже есть"): + downloads.start_download("http://127.0.0.1:9/small.iso", "iso") + + +def test_start_download_checks_free_space(data_dirs, monkeypatch): + monkeypatch.setattr(downloads.shutil, "disk_usage", lambda path: SimpleNamespace(free=1000, total=1000, used=0)) + with pytest.raises(downloads.DownloadError, match="мало места"): + downloads.start_download("http://127.0.0.1:9/small.iso", "iso") + + +def test_image_candidates(): + assert downloads.image_candidates("a.img.xz") == ["a.img.xz", "a.img", "a.qcow2"] + assert downloads.image_candidates("a.img") == ["a.img", "a.qcow2"] + assert downloads.image_candidates("a.qcow2") == ["a.qcow2"] + + +# ---------------------------------------------------------------- background download +def test_download_iso_success_refreshes_pool(data_dirs, www, monkeypatch): + calls = [] + monkeypatch.setattr(downloads, "run_cmd", lambda cmd, timeout=12: calls.append(cmd) or {"ok": True, "stdout": "", "stderr": ""}) + operation = downloads.start_download(f"{www.url}/small.iso", "iso") + assert operation["type"] == "download" and operation["download_kind"] == "iso" + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert done["progress"] == 100 + assert done["total_bytes"] == 15000 and done["downloaded_bytes"] == 15000 + assert (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000 + assert not (data_dirs["iso"] / ".small.iso.part").exists() + assert ["virsh", "pool-refresh", "virtuality-iso"] in calls + assert "Готово" in done["message"] + + +def test_download_follows_redirect(data_dirs, www): + operation = downloads.start_download(f"{www.url}/redirect.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert (data_dirs["iso"] / "redirect.iso").stat().st_size == 15000 + assert "Перенаправление" in done["log_tail"] + + +def test_download_missing_file_fails(data_dirs, www): + operation = downloads.start_download(f"{www.url}/nope.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "404" in done["message"] + assert not list(data_dirs["iso"].iterdir()) + + +def test_download_refuses_when_disk_fills_up(data_dirs, www, monkeypatch): + real = downloads.free_space_ok + seen = [] + + def fake(directory, needed): + seen.append(needed) + return real(directory, needed) if len(seen) == 1 else (False, 42) + + monkeypatch.setattr(downloads, "free_space_ok", fake) + operation = downloads.start_download(f"{www.url}/small.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "мало места" in done["message"] and "42 Б" in done["message"] + assert not list(data_dirs["iso"].iterdir()) + + +def test_download_cancel(data_dirs, www, logged_in): + operation = downloads.start_download(f"{www.url}/slow.iso", "iso") + wait_until(lambda: (core.read_operation(operation["id"]) or {}).get("status") == "running") + wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists()) + response = logged_in.post(f"/api/operations/{operation['id']}/cancel") + assert response.status_code == 200 and response.json()["ok"] is True + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert done["message"] == "Скачивание отменено" + assert done["cancelled"] is True and done["cancel_requested"] is True + assert not (data_dirs["iso"] / ".slow.iso.part").exists() + assert not (data_dirs["iso"] / "slow.iso").exists() + assert not downloads.cancel_requested(operation["id"]) + + +def test_catalog_download_verifies_checksum(data_dirs, www): + payload = b"QCOW2 image bytes" * 100 + (www.root / "disk.qcow2").write_bytes(payload) + (www.root / "SHA256SUMS").write_text(f"{hashlib.sha256(payload).hexdigest()} *disk.qcow2\n") + operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256", catalog_id="test") + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert "совпала" in done["log_tail"] + assert (data_dirs["disk_images"] / "disk.qcow2").read_bytes() == payload + + +def test_catalog_download_checksum_mismatch_deletes_file(data_dirs, www): + (www.root / "disk.qcow2").write_bytes(b"corrupted" * 100) + (www.root / "SHA512SUMS").write_text(f"{'0' * 128} disk.qcow2\n") + operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA512SUMS", algo="sha512") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "Контрольная сумма не совпала" in done["message"] + assert not list(data_dirs["disk_images"].iterdir()) + + +def test_catalog_download_fails_without_sums_entry(data_dirs, www): + (www.root / "disk.qcow2").write_bytes(b"data") + (www.root / "SHA256SUMS").write_text(f"{'a' * 64} *other.qcow2\n") + operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "нет записи" in done["message"] + assert not list(data_dirs["disk_images"].iterdir()) + + +def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeypatch): + raw = bytes(range(256)) * 256 + (www.root / "pi.img.xz").write_bytes(lzma.compress(raw)) + started = [] + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path) or {"id": "conv-1"}) + operation = downloads.start_download(f"{www.url}/pi.img.xz", "disk") + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert (data_dirs["disk_images"] / "pi.img").read_bytes() == raw + assert not (data_dirs["disk_images"] / "pi.img.xz").exists() + assert started == [data_dirs["disk_images"] / "pi.img"] + assert done["convert_operation_id"] == "conv-1" + assert "qcow2" in done["message"] + + +def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch): + (www.root / "ready.qcow2").write_bytes(b"q" * 10) + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None) + done = wait_for(downloads.start_download(f"{www.url}/ready.qcow2", "disk")["id"]) + assert done["status"] == "success" and "convert_operation_id" not in done + assert done["result_path"] == str(data_dirs["disk_images"] / "ready.qcow2") + + +# ---------------------------------------------------------------- routes +def test_url_route_starts_download(logged_in, data_dirs, www): + response = logged_in.post("/downloads/url", data={"url": f"{www.url}/small.iso", "kind": "iso"}, follow_redirects=False) + assert response.status_code == 303 + location = unquote(response.headers["location"]) + assert location.startswith("/iso?download_message=") and "small.iso" in location + running = downloads.active_downloads("iso") + assert len(running) == 1 and running[0]["filename"] == "small.iso" + assert wait_for(running[0]["id"])["status"] == "success" + assert (data_dirs["iso"] / "small.iso").exists() + + +def test_url_route_rejects_bad_links(logged_in, data_dirs): + response = logged_in.post("/downloads/url", data={"url": "ftp://example.org/x.iso", "kind": "iso"}, follow_redirects=False) + assert response.status_code == 303 + assert "download_error=" in response.headers["location"] and response.headers["location"].startswith("/iso?") + response = logged_in.post("/downloads/url", data={"url": "https://example.org/setup.exe", "kind": "disk"}, follow_redirects=False) + assert response.headers["location"].startswith("/disk-images?download_error=") + assert not core.running_operations("download") + + +def test_url_route_requires_login(client): + assert client.post("/downloads/url", data={"url": "https://example.org/x.iso"}, follow_redirects=False).headers["location"] == "/login" + assert client.post("/api/operations/x/cancel").status_code == 401 + + +def test_catalog_route(logged_in, data_dirs, www, monkeypatch): + payload = b"debian" * 1000 + (www.root / "debian-13-genericcloud-amd64.qcow2").write_bytes(payload) + (www.root / "SHA512SUMS").write_text(f"{hashlib.sha512(payload).hexdigest()} debian-13-genericcloud-amd64.qcow2\n") + entry = {"id": "debian-13", "title": "Debian 13", "hint": "x", "login": "debian", "arches": {"x86_64": {"url": f"{www.url}/debian-13-genericcloud-amd64.qcow2", "sums": f"{www.url}/SHA512SUMS", "algo": "sha512"}}} + monkeypatch.setattr(catalog, "CLOUD_IMAGES", [entry]) + monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64") + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None) + + assert unquote(logged_in.post("/downloads/catalog/nope", follow_redirects=False).headers["location"]).startswith("/disk-images?download_error=Такого образа нет") + response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False) + assert response.status_code == 303 and "download_message=" in response.headers["location"] + page = logged_in.get("/disk-images").text + assert "Скачивается" in page + running = downloads.active_downloads("disk") + assert running[0]["catalog_id"] == "debian-13" + assert wait_for(running[0]["id"])["status"] == "success" + assert (data_dirs["disk_images"] / "debian-13-genericcloud-amd64.qcow2").read_bytes() == payload + page = logged_in.get("/disk-images").text + assert "Загружен" in page and "/vm/create?image=debian-13-genericcloud-amd64.qcow2" in page + # второй раз — файл уже есть + response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False) + assert "download_error=" in response.headers["location"] + + +def test_catalog_route_skips_missing_arch(logged_in, data_dirs, monkeypatch): + monkeypatch.setattr(catalog, "CLOUD_IMAGES", [{"id": "only-arm", "title": "ARM only", "arches": {"aarch64": {"url": "https://example.org/a.qcow2"}}}]) + monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64") + location = unquote(logged_in.post("/downloads/catalog/only-arm", follow_redirects=False).headers["location"]) + assert "не выпускается для процессора x86_64" in location + assert downloads.catalog_entries() == [] + assert "готовых образов в каталоге нет" in logged_in.get("/disk-images").text + + +def test_cancel_api_rules(logged_in, data_dirs): + assert logged_in.post("/api/operations/00000000-0000-0000-0000-000000000000/cancel").status_code == 404 + other = core.new_operation("vm_create", "Создание VM x") + response = logged_in.post(f"/api/operations/{other['id']}/cancel") + assert response.status_code == 400 and "только скачивание" in response.json()["error"] + finished = core.new_operation("download", "Скачивание x", download_kind="iso", filename="x.iso") + core.finish_operation(finished, True, "Готово") + assert logged_in.post(f"/api/operations/{finished['id']}/cancel").status_code == 409 + running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") + core.update_operation(running, status="running") + response = logged_in.post(f"/api/operations/{running['id']}/cancel") + assert response.status_code == 200 and response.json()["operation"]["cancel_requested"] is True + assert downloads.cancel_requested(running["id"]) + downloads.clear_cancel(running["id"]) + + +def test_cancel_form_redirects_to_page(logged_in, data_dirs): + running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") + core.update_operation(running, status="running") + response = logged_in.post(f"/downloads/{running['id']}/cancel", follow_redirects=False) + assert response.status_code == 303 + assert unquote(response.headers["location"]).startswith("/disk-images?download_message=Останавливаем скачивание y.qcow2") + assert unquote(logged_in.post("/downloads/nope/cancel", follow_redirects=False).headers["location"]).startswith("/iso?download_error=") + downloads.clear_cancel(running["id"]) + + +def test_pages_show_download_cards(logged_in, data_dirs): + iso_page = logged_in.get("/iso").text + assert "Скачать по ссылке" in iso_page and 'name="kind" value="iso"' in iso_page + assert "Скачиваем на сервер" not in iso_page + disk_page = logged_in.get("/disk-images").text + assert "Каталог готовых систем" in disk_page and "Рекомендуем" in disk_page and 'name="kind" value="disk"' in disk_page + running = core.new_operation("download", "Скачивание big.iso", download_kind="iso", filename="big.iso", url="https://example.org/big.iso", total_bytes=None) + core.update_operation(running, status="running", progress=0, message="Подключаемся к серверу…") + iso_page = logged_in.get("/iso?download_message=Скачиваем").text + assert "Скачиваем на сервер" in iso_page and "big.iso" in iso_page and "indeterminate" in iso_page + assert f"/downloads/{running['id']}/cancel" in iso_page and "Скачиваем" in iso_page + assert "big.iso" not in logged_in.get("/disk-images").text diff --git a/web/app.py b/web/app.py index c911878..0d0653a 100644 --- a/web/app.py +++ b/web/app.py @@ -2016,3 +2016,7 @@ def api_health(request: Request): if not get_current_user(request): return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()} + + +import features.downloads as downloads # noqa: E402 +app.include_router(downloads.router) diff --git a/web/catalog.py b/web/catalog.py new file mode 100644 index 0000000..014871b --- /dev/null +++ b/web/catalog.py @@ -0,0 +1,82 @@ +"""Catalog of ready-to-run cloud images for the quick VM flow. + +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. +""" +from typing import Any + +# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13. +CLOUD_IMAGES: list[dict[str, Any]] = [ + { + "id": "ubuntu-26.04", + "title": "Ubuntu Server 26.04 LTS", + "vendor": "Ubuntu", + "hint": "Самая свежая LTS, поддержка до 2031 года. Рекомендуем.", + "osinfo": "ubuntu24.04", + "recommended": True, + "login": "ubuntu", + "arches": { + "x86_64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"}, + "aarch64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"}, + }, + }, + { + "id": "ubuntu-24.04", + "title": "Ubuntu Server 24.04 LTS", + "vendor": "Ubuntu", + "hint": "Проверенная LTS, поддержка до 2029 года.", + "osinfo": "ubuntu24.04", + "login": "ubuntu", + "arches": { + "x86_64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"}, + "aarch64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"}, + }, + }, + { + "id": "debian-13", + "title": "Debian 13", + "vendor": "Debian", + "hint": "Лёгкий и стабильный. Хорош для домашних сервисов.", + "osinfo": "debian13", + "login": "debian", + "arches": { + "x86_64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"}, + "aarch64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"}, + }, + }, + { + "id": "alpine-3.23", + "title": "Alpine Linux 3.23", + "vendor": "Alpine", + "hint": "Очень маленький: 200 МБ на диске, стартует мгновенно.", + "osinfo": "alpinelinux3.21", + "login": "alpine", + "arches": { + "x86_64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True}, + "aarch64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True}, + }, + }, +] + +# OS families for the "install from ISO" flow. virt-install picks the right +# virtual hardware from osinfo: Windows gets SATA/e1000e (works without extra +# drivers), win11 additionally gets UEFI + TPM. +OS_TYPES: list[dict[str, Any]] = [ + {"id": "linux", "title": "Linux", "hint": "Ubuntu, Debian, Fedora и другие", "osinfo": "detect=on,require=off"}, + {"id": "windows11", "title": "Windows 11 / Server 2025", "hint": "Нужны UEFI и TPM — включим сами", "osinfo": "win11"}, + {"id": "windows10", "title": "Windows 10 / Server 2019–2022", "hint": "Классическая загрузка", "osinfo": "win10"}, + {"id": "other", "title": "Другая система", "hint": "FreeBSD, старые ОС, экзотика", "osinfo": "generic"}, +] + + +def cloud_image(image_id: str) -> dict[str, Any] | None: + return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None) + + +def os_type(type_id: str) -> dict[str, Any]: + return next((item for item in OS_TYPES if item["id"] == type_id), OS_TYPES[0]) + + +def image_filename(entry: dict[str, Any], arch: str) -> str: + url = entry["arches"][arch]["url"] + return url.rsplit("/", 1)[-1] diff --git a/web/core.py b/web/core.py new file mode 100644 index 0000000..cf7b9a1 --- /dev/null +++ b/web/core.py @@ -0,0 +1,321 @@ +"""Shared primitives of the Virtuality panel: settings, auth, command runner, +background operations store, templates. Feature modules import from here; +app.py re-exports the same names for backwards compatibility.""" +import json +import os +import re +import subprocess +import threading +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Callable +from urllib.parse import quote, urlsplit + +from fastapi import Request +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from itsdangerous import BadSignature, URLSafeSerializer, URLSafeTimedSerializer + +import auth +import presenters +import update_core + +BASE_DIR = Path(__file__).resolve().parent +APP_NAME = "Virtuality" +ENV_FILE = BASE_DIR / ".env" +STORAGE_DIR = Path("/var/lib/virtuality") +ISO_DIR = Path("/var/lib/virtuality/iso") +IMAGES_DIR = Path("/var/lib/virtuality/images") +DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images") +BACKUPS_DIR = Path("/var/lib/virtuality/backups") +CONFIG_DIR = Path("/var/lib/virtuality/config") +OPERATIONS_DIR = Path("/var/log/virtuality/operations") +DEFAULT_BRIDGE = "br0" +IMAGES_POOL = "virtuality-images" +ISO_POOL = "virtuality-iso" + +OP_LOCK = threading.Lock() + + +def load_env() -> dict[str, str]: + data: dict[str, str] = {} + if ENV_FILE.exists(): + for raw_line in ENV_FILE.read_text().splitlines(): + line = raw_line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + data[key.strip()] = value.strip().strip('"').strip("'") + return data + + +CONFIG = load_env() +AUTH_USER = CONFIG.get("VIRTUALITY_AUTH_USER", os.environ.get("VIRTUALITY_AUTH_USER", "root")) +SESSION_SECRET = CONFIG.get("VIRTUALITY_SESSION_SECRET", os.environ.get("VIRTUALITY_SESSION_SECRET", "dev-secret-change-me")) +COOKIE_SECURE = CONFIG.get("VIRTUALITY_COOKIE_SECURE", os.environ.get("VIRTUALITY_COOKIE_SECURE", "0")) == "1" +SESSION_MAX_AGE = 60 * 60 * 12 +login_throttle = auth.LoginThrottle() +serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="virtuality-session") +console_serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-console") + + +def is_configured() -> bool: + return bool(AUTH_USER and SESSION_SECRET != "dev-secret-change-me") + + +def read_version() -> str: + for path in (BASE_DIR / "VERSION", BASE_DIR.parent / "VERSION"): + try: + return path.read_text().strip() or "unknown" + except OSError: + continue + return "unknown" + + +APP_VERSION = read_version() + + +# ---------------------------------------------------------------- templates +def update_notice() -> dict[str, Any]: + try: + data = json.loads((update_core.STATE_DIR / "last_check.json").read_text()) + except Exception: + return {"has_update": False} + return {"has_update": bool(data.get("has_update")), "latest_version": data.get("latest_version", "")} + + +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) +templates.env.globals.update( + update_notice=update_notice, + vm_presets=presenters.VM_PRESETS, + port_presets=presenters.PORT_PRESETS, + plural=presenters.plural, + app_version=APP_VERSION, + app_name=APP_NAME, +) +templates.env.filters.update( + vm_state=presenters.vm_state, + operation_state=presenters.operation_state, + service_state=presenters.service_state, + format_mb=presenters.format_mb, + format_bytes=presenters.format_bytes, + level_tone=presenters.level_tone, + port_service=presenters.port_service, +) + + +def render(request: Request, template: str, context: dict[str, Any] | None = None, status_code: int = 200, **extra: Any): + data = {"request": request, "app_name": APP_NAME, "user": AUTH_USER} + data.update(context or {}) + data.update(extra) + return templates.TemplateResponse(template, data, status_code=status_code) + + +# ---------------------------------------------------------------- auth +def verify_linux_password(username: str, password: str) -> bool: + if username != AUTH_USER: + return False + return auth.verify_password(username, password) + + +def user_from_session_token(token: str | None) -> str | None: + if not token: + return None + try: + data = serializer.loads(token, max_age=SESSION_MAX_AGE) + except BadSignature: + return None + if not isinstance(data, dict): + return None + return AUTH_USER if data.get("user") == AUTH_USER else None + + +def get_current_user(request: Request) -> str | None: + return user_from_session_token(request.cookies.get("virtuality_session")) + + +def require_auth(request: Request): + """Return a redirect to /login for anonymous requests, otherwise None.""" + if not get_current_user(request): + return RedirectResponse(url="/login", status_code=303) + return None + + +def require_api_auth(request: Request): + """JSON variant of require_auth for /api and /live endpoints.""" + if not get_current_user(request): + return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) + return None + + +def client_key(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def same_origin(request: Request) -> bool: + origin = request.headers.get("origin") or request.headers.get("referer") + if not origin or origin == "null": + # Browsers always send Origin on cross-site form posts; tools like curl do not. + return origin != "null" + origin_host = urlsplit(origin).netloc.lower() + allowed = {request.headers.get("host", "").lower()} + forwarded = request.headers.get("x-forwarded-host") + if forwarded: + allowed.update(item.strip().lower() for item in forwarded.split(",")) + return origin_host in allowed + + +def redirect_with_message(path: str, key: str, message: str) -> RedirectResponse: + return RedirectResponse(url=f"{path}?{key}={quote(message)}", status_code=303) + + +# ---------------------------------------------------------------- commands +def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) + return {"ok": result.returncode == 0, "code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "cmd": " ".join(cmd)} + except Exception as exc: + return {"ok": False, "code": -1, "stdout": "", "stderr": str(exc), "cmd": " ".join(cmd)} + + +def cmd_error(result: dict[str, Any], fallback: str) -> str: + """Best human-readable error text from a run_cmd result.""" + text = (result.get("stderr") or result.get("stdout") or "").strip() + text = re.sub(r"^error:\s*", "", text, flags=re.IGNORECASE) + return text.splitlines()[0] if text else fallback + + +def utc_now() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def tail_text(path: Path, max_lines: int = 220) -> str: + if not path.exists(): + return "" + try: + return "\n".join(path.read_text(errors="replace").splitlines()[-max_lines:]) + except OSError: + return "" + + +def valid_vm_name(name: str) -> bool: + return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or "")) + + +def valid_label(name: str) -> bool: + """Snapshot, backup and similar user-chosen names.""" + return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,62}", name or "")) + + +def vm_exists(name: str) -> bool: + return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"] + + +def vm_state(name: str) -> str: + result = run_cmd(["virsh", "domstate", name], timeout=8) + return (result.get("stdout") or "unknown").strip().lower() + + +def vm_is_running(name: str) -> bool: + return vm_state(name) in ("running", "idle", "blocked", "paused", "pmsuspended", "in shutdown") + + +# ---------------------------------------------------------------- operations +def operation_meta_path(operation_id: str) -> Path: + return OPERATIONS_DIR / f"{operation_id}.json" + + +def operation_log_path(operation_id: str) -> Path: + return OPERATIONS_DIR / f"{operation_id}.log" + + +def ensure_operations_dir() -> None: + OPERATIONS_DIR.mkdir(parents=True, exist_ok=True) + + +def write_operation(operation: dict[str, Any]) -> None: + ensure_operations_dir() + path = operation_meta_path(operation["id"]) + tmp_path = path.with_suffix(".json.tmp") + with OP_LOCK: + tmp_path.write_text(json.dumps(operation, ensure_ascii=False, indent=2)) + tmp_path.replace(path) + + +def read_operation(operation_id: str) -> dict[str, Any] | None: + if not re.fullmatch(r"[a-f0-9-]{36}", operation_id or ""): + return None + path = operation_meta_path(operation_id) + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + except Exception: + return None + data["log_tail"] = tail_text(operation_log_path(operation_id)) + return data + + +def list_operations(limit: int = 25) -> list[dict[str, Any]]: + ensure_operations_dir() + operations: list[dict[str, Any]] = [] + for path in sorted(OPERATIONS_DIR.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True): + try: + data = json.loads(path.read_text()) + data["log_tail"] = tail_text(operation_log_path(data["id"]), max_lines=20) + operations.append(data) + except Exception: + continue + if len(operations) >= limit: + break + return operations + + +def running_operations(kind: str | None = None) -> list[dict[str, Any]]: + return [op for op in list_operations(50) if op.get("status") in ("queued", "running") and (kind is None or op.get("type") == kind)] + + +def append_operation_log(operation_id: str, message: str) -> None: + ensure_operations_dir() + with operation_log_path(operation_id).open("a", encoding="utf-8") as handle: + handle.write(f"[{utc_now()}] {message.rstrip()}\n") + + +def operation_css(status: str) -> str: + return "ok" if status == "success" else "err" if status == "error" else "warn" + + +def update_operation(operation: dict[str, Any], **changes: Any) -> None: + operation.update(changes) + operation["updated_at"] = utc_now() + write_operation(operation) + + +def new_operation(kind: str, title: str, message: str = "Операция поставлена в очередь", **meta: Any) -> dict[str, Any]: + operation = {"id": str(uuid.uuid4()), "type": kind, "title": title, "status": "queued", "progress": 0, "message": message, "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER} + operation.update(meta) + write_operation(operation) + append_operation_log(operation["id"], "Операция поставлена в очередь.") + return operation + + +def finish_operation(operation: dict[str, Any], ok: bool, message: str, **meta: Any) -> None: + fresh = read_operation(operation["id"]) or operation + fresh.pop("log_tail", None) + update_operation(fresh, status="success" if ok else "error", progress=100, message=message, finished_at=utc_now(), **meta) + append_operation_log(operation["id"], message) + + +def run_operation(operation: dict[str, Any], worker: Callable[[dict[str, Any]], None]) -> None: + """Run worker(operation) in a daemon thread; the worker reports progress via + update_operation/append_operation_log and must call finish_operation.""" + + def runner() -> None: + try: + update_operation(operation, status="running", started_at=utc_now()) + worker(operation) + except Exception as exc: # noqa: BLE001 — surface any failure to the user + append_operation_log(operation["id"], f"Ошибка: {exc}") + finish_operation(operation, False, str(exc)[:240]) + + threading.Thread(target=runner, daemon=True).start() diff --git a/web/features/__init__.py b/web/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/features/downloads.py b/web/features/downloads.py new file mode 100644 index 0000000..4c13ab3 --- /dev/null +++ b/web/features/downloads.py @@ -0,0 +1,578 @@ +"""Скачивание образов ОС по ссылке и из встроенного каталога готовых систем. + +Файл качается на сервере в фоне как операция типа «download»: прогресс, +скорость и оставшееся время видны на страницах «Образы ОС» и в «Задачах». +Пока идёт загрузка, файл лежит рядом как `.<имя>.part` и переименовывается +только после успешного завершения (и проверки контрольной суммы для +образов из каталога). Скачивание можно отменить — недокачанный файл удаляется. +""" +import hashlib +import re +import shutil +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlsplit + +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse + +import catalog +import core +import host_profile +from core import ( + APP_VERSION, + append_operation_log, + finish_operation, + new_operation, + read_operation, + redirect_with_message, + require_api_auth, + require_auth, + run_cmd, + run_operation, + running_operations, + update_operation, +) + +router = APIRouter() + +CHUNK_SIZE = 1024 * 1024 +TIMEOUT = 30 +RESERVE_BYTES = 512 * 1024 * 1024 +SUMS_MAX_BYTES = 4 * 1024 * 1024 +USER_AGENT = f"Virtuality/{APP_VERSION}" +ISO_POOL = "virtuality-iso" + +KINDS: dict[str, dict[str, Any]] = { + "iso": {"suffixes": (".iso",), "page": "/iso", "label": "установочный образ", "hint": "Ссылка должна вести на файл .iso"}, + "disk": {"suffixes": (".qcow2", ".img", ".raw", ".img.xz"), "page": "/disk-images", "label": "образ диска", "hint": "Подойдут файлы .qcow2, .img, .raw и сжатые .img.xz"}, +} + +# Отмена: идентификаторы операций, которые попросили остановить. Поток +# загрузки проверяет множество между кусками файла. +CANCEL_LOCK = threading.Lock() +CANCEL_REQUESTS: set[str] = set() + + +class DownloadError(ValueError): + """Понятная пользователю ошибка проверки ссылки или файла.""" + + +# ---------------------------------------------------------------- formatting +def human_size(value: float | int | None) -> str: + """1234567 → «1,2 МБ». Десятичная запятая, как принято в русском.""" + value = float(value or 0) + for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"): + if value < 1024 or unit == "ТБ": + if unit == "Б" or value >= 100: + return f"{value:.0f} {unit}" + text = f"{value:.1f}".rstrip("0").rstrip(".") + return f"{text.replace('.', ',')} {unit}" + value /= 1024 + return f"{value:.0f} ТБ" + + +def human_eta(seconds: float | None) -> str: + """Оставшееся время словами: «40 с», «4 мин», «1 ч 12 мин».""" + if seconds is None or seconds < 0: + return "" + seconds = int(round(seconds)) + if seconds < 60: + return f"{max(seconds, 1)} с" + minutes = (seconds + 30) // 60 + if minutes < 60: + return f"{minutes} мин" + hours, minutes = divmod(minutes, 60) + return f"{hours} ч {minutes} мин" if minutes else f"{hours} ч" + + +def progress_message(done: int, total: int | None, speed: float | None) -> str: + """«245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин».""" + parts = [f"{human_size(done)} из {human_size(total)}" if total else human_size(done)] + if speed and speed > 0: + parts.append(f"{human_size(speed)}/с") + if total and total > done: + parts.append(f"осталось {human_eta((total - done) / speed)}") + return " · ".join(parts) + + +# ---------------------------------------------------------------- validation +def validate_url(url: str) -> str: + url = (url or "").strip() + if not url: + raise DownloadError("Вставьте ссылку на файл") + if len(url) > 2048 or any(ch.isspace() for ch in url): + raise DownloadError("Ссылка выглядит некорректно") + parts = urlsplit(url) + if parts.scheme.lower() not in ("http", "https"): + raise DownloadError("Ссылка должна начинаться с http:// или https://") + if not parts.hostname: + raise DownloadError("В ссылке не указан адрес сервера") + return url + + +def filename_from_url(url: str, kind: str) -> str: + """Имя файла из ссылки, приведённое к безопасному виду (как при загрузке с компьютера).""" + if kind not in KINDS: + raise DownloadError("Неизвестный тип образа") + original = unquote(urlsplit(url).path).rstrip("/").rsplit("/", 1)[-1].strip() + if not original: + raise DownloadError("В ссылке нет имени файла — она должна вести на сам файл, а не на страницу") + lower = original.lower() + suffix = next((item for item in KINDS[kind]["suffixes"] if lower.endswith(item)), None) + if not suffix: + allowed = ", ".join(KINDS[kind]["suffixes"]) + raise DownloadError(f"Файл «{original}» не подходит: нужен {KINDS[kind]['label']} ({allowed})") + stem = original[: -len(suffix)] + 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-iso" if kind == "iso" else "virtuality-disk" + name = f"{stem}{suffix}" + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,180}", name): + raise DownloadError("Не удалось составить безопасное имя файла из ссылки") + return name + + +def kind_dir(kind: str) -> Path: + return core.ISO_DIR if kind == "iso" else core.DISK_IMAGES_DIR + + +def target_path(kind: str, filename: str) -> Path: + directory = kind_dir(kind) + directory.mkdir(parents=True, exist_ok=True) + path = (directory / filename).resolve() + if directory.resolve() not in path.parents: + raise DownloadError("Недопустимое имя файла") + return path + + +def part_path(target: Path) -> Path: + return target.with_name(f".{target.name}.part") + + +def free_space_ok(directory: Path, needed: int | None) -> tuple[bool, int]: + free = shutil.disk_usage(str(directory)).free + return free >= (needed or 0) + RESERVE_BYTES, free + + +# ---------------------------------------------------------------- checksums +HASH_RE = re.compile(r"^[0-9a-fA-F]{32,128}$") + + +def parse_checksums(text: str, filename: str) -> str | None: + """Хеш файла из файла контрольных сумм. + + Понимает « *name» и « name» (Ubuntu, Debian), BSD-стиль + «SHA256 (name) = » и файл из одного хеша (Alpine *.sha512). + """ + bare: str | None = None + for raw in (text or "").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + bsd = re.match(r"^[A-Za-z0-9-]+\s*\((.+)\)\s*=\s*([0-9a-fA-F]{32,128})$", line) + if bsd: + if Path(bsd.group(1).strip()).name == filename: + return bsd.group(2).lower() + continue + pieces = line.split(None, 1) + if len(pieces) == 2 and HASH_RE.match(pieces[0]): + name = pieces[1].strip().lstrip("*").strip() + if Path(name).name == filename: + return pieces[0].lower() + continue + if len(pieces) == 1 and HASH_RE.match(pieces[0]) and bare is None: + bare = pieces[0].lower() + return bare + + +def file_digest(path: Path, algo: str, on_progress: Any = None) -> str: + digest = hashlib.new(algo) + with path.open("rb") as handle: + while True: + chunk = handle.read(CHUNK_SIZE) + if not chunk: + break + digest.update(chunk) + if on_progress: + on_progress(len(chunk)) + return digest.hexdigest() + + +# ---------------------------------------------------------------- catalog +def host_arch() -> str: + arch = str(host_profile.load_host_profile().get("arch") or "").lower() + return {"amd64": "x86_64", "arm64": "aarch64"}.get(arch, arch or "x86_64") + + +def image_candidates(filename: str) -> list[str]: + """Имена файлов, под которыми образ может лежать после скачивания: + сам файл, распакованный .img для .img.xz и qcow2 после конвертации.""" + names = [filename] + lower = filename.lower() + if lower.endswith(".img.xz"): + names.append(filename[:-3]) + names.append(filename[:-7] + ".qcow2") + elif lower.endswith((".img", ".raw")): + names.append(filename[: filename.rfind(".")] + ".qcow2") + return names + + +def catalog_entries(arch: str | None = None) -> list[dict[str, Any]]: + """Каталог готовых систем для процессора сервера с пометками «Загружен» / «Скачивается».""" + arch = arch or host_arch() + active = {op.get("catalog_id"): op for op in running_operations("download") if op.get("catalog_id")} + entries: list[dict[str, Any]] = [] + for item in catalog.CLOUD_IMAGES: + source = item.get("arches", {}).get(arch) + if not source: + continue + try: + filename = filename_from_url(source["url"], "disk") + except DownloadError: + continue + ready = next((name for name in reversed(image_candidates(filename)) if (core.DISK_IMAGES_DIR / name).exists()), None) + operation = active.get(item["id"]) + entries.append({ + "id": item["id"], "title": item["title"], "vendor": item.get("vendor", ""), "hint": item.get("hint", ""), + "recommended": bool(item.get("recommended")), "login": item.get("login", ""), "filename": filename, + "uefi": bool(source.get("uefi")), "status": "downloading" if operation else "ready" if ready else "none", + "ready_file": ready, "operation_id": operation["id"] if operation else None, + }) + return entries + + +def active_downloads(kind: str | None = None) -> list[dict[str, Any]]: + result = [] + for op in running_operations("download"): + if kind and op.get("download_kind") != kind: + continue + op["indeterminate"] = not op.get("total_bytes") and op.get("status") == "running" + result.append(op) + return result + + +def downloads_panel(kind: str) -> dict[str, Any]: + """Данные для карточек скачивания на страницах /iso и /disk-images (глобальная функция шаблонов).""" + kind = kind if kind in KINDS else "iso" + return {"kind": kind, "page": KINDS[kind]["page"], "hint": KINDS[kind]["hint"], "active": active_downloads(kind), "catalog": catalog_entries() if kind == "disk" else []} + + +# ---------------------------------------------------------------- cancel +def cancel_requested(operation_id: str) -> bool: + with CANCEL_LOCK: + return operation_id in CANCEL_REQUESTS + + +def clear_cancel(operation_id: str) -> None: + with CANCEL_LOCK: + CANCEL_REQUESTS.discard(operation_id) + + +def request_cancel(operation_id: str) -> tuple[dict[str, Any] | None, str | None]: + """Попросить остановить скачивание. Возвращает (операция, ошибка).""" + operation = read_operation(operation_id) + if not operation: + return None, "Задача не найдена" + if operation.get("type") != "download": + return operation, "Отменить можно только скачивание" + if operation.get("status") not in ("queued", "running"): + return operation, "Скачивание уже завершено" + with CANCEL_LOCK: + CANCEL_REQUESTS.add(operation_id) + operation.pop("log_tail", None) + update_operation(operation, cancel_requested=True, message="Останавливаем скачивание…") + append_operation_log(operation_id, "Пользователь попросил отменить скачивание.") + return operation, None + + +# ---------------------------------------------------------------- download +def start_download(url: str, kind: str, *, title: str | None = None, sums_url: str | None = None, algo: str | None = None, catalog_id: str | None = None) -> dict[str, Any]: + """Проверить ссылку и запустить фоновое скачивание. Бросает DownloadError.""" + if kind not in KINDS: + raise DownloadError("Неизвестный тип образа") + url = validate_url(url) + filename = filename_from_url(url, kind) + target = target_path(kind, filename) + if target.exists(): + raise DownloadError(f"Файл {filename} уже есть на сервере. Удалите его, если нужно скачать заново") + if any(op.get("target_path") == str(target) for op in running_operations("download")): + raise DownloadError(f"Файл {filename} уже скачивается") + if sums_url: + validate_url(sums_url) + if algo not in hashlib.algorithms_available: + raise DownloadError("Неизвестный алгоритм контрольной суммы") + ok, free = free_space_ok(target.parent, None) + if not ok: + raise DownloadError(f"На сервере мало места: свободно {human_size(free)}") + operation = new_operation( + "download", title or f"Скачивание {filename}", message="Подключаемся к серверу…", + download_kind=kind, url=url, filename=filename, target_path=str(target), source_path=str(target), page=KINDS[kind]["page"], + sums_url=sums_url, algo=algo, catalog_id=catalog_id, total_bytes=None, downloaded_bytes=0, cancel_requested=False, + ) + append_operation_log(operation["id"], f"Ссылка: {url}") + append_operation_log(operation["id"], f"Файл: {target}") + run_operation(operation, download_worker) + return operation + + +def open_url(url: str) -> Any: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "*/*"}) + return urllib.request.urlopen(request, timeout=TIMEOUT) # noqa: S310 — схема проверена в validate_url + + +def describe_network_error(exc: Exception) -> str: + if isinstance(exc, urllib.error.HTTPError): + return f"Сервер ответил ошибкой {exc.code} {exc.reason}" + if isinstance(exc, urllib.error.URLError): + reason = exc.reason + if isinstance(reason, TimeoutError) or "timed out" in str(reason).lower(): + return f"Сервер не отвечает (ждали {TIMEOUT} с)" + return f"Не удалось подключиться к серверу: {reason}" + if isinstance(exc, TimeoutError) or "timed out" in str(exc).lower(): + return f"Сервер перестал отвечать (ждали {TIMEOUT} с)" + return str(exc)[:240] + + +def fetch_expected_checksum(sums_url: str, filename: str, operation_id: str) -> str: + append_operation_log(operation_id, f"Скачиваем контрольные суммы: {sums_url}") + try: + with open_url(sums_url) as response: + text = response.read(SUMS_MAX_BYTES).decode("utf-8", errors="replace") + except Exception as exc: # noqa: BLE001 + raise DownloadError(f"Не удалось скачать файл контрольных сумм: {describe_network_error(exc)}") from exc + expected = parse_checksums(text, filename) + if not expected: + raise DownloadError(f"В файле контрольных сумм нет записи для {filename}") + return expected + + +def download_worker(operation: dict[str, Any]) -> None: + operation_id = operation["id"] + kind = operation["download_kind"] + target = Path(operation["target_path"]) + part = part_path(target) + verify = bool(operation.get("sums_url")) + download_share = 96 if verify else 100 + + def cancelled() -> bool: + return cancel_requested(operation_id) + + def fail(message: str, **meta: Any) -> None: + part.unlink(missing_ok=True) + clear_cancel(operation_id) + finish_operation(operation, False, message, **meta) + + def stop() -> None: + append_operation_log(operation_id, "Скачивание остановлено, недокачанный файл удалён.") + fail("Скачивание отменено", cancelled=True, cancel_requested=True) + + if cancelled(): + stop() + return + try: + response = open_url(operation["url"]) + except Exception as exc: # noqa: BLE001 + fail(describe_network_error(exc)) + return + + with response: + final_url = response.geturl() + if final_url != operation["url"]: + append_operation_log(operation_id, f"Перенаправление: {final_url}") + length = response.headers.get("Content-Length") + total = int(length) if length and length.isdigit() else None + ok, free = free_space_ok(target.parent, total) + if not ok: + need = f"нужно {human_size((total or 0) + RESERVE_BYTES)}, " if total else "" + fail(f"На сервере мало места: {need}свободно {human_size(free)}") + return + append_operation_log(operation_id, f"Размер файла: {human_size(total) if total else 'неизвестен'}") + update_operation(operation, total_bytes=total, message=progress_message(0, total, None)) + + done = 0 + started = last_tick = time.monotonic() + last_done = 0 + speed: float | None = None + try: + with part.open("wb") as handle: + while True: + if cancelled(): + stop() + return + chunk = response.read(CHUNK_SIZE) + if not chunk: + break + handle.write(chunk) + done += len(chunk) + now = time.monotonic() + if now - last_tick >= 1.0: + instant = (done - last_done) / (now - last_tick) + speed = instant if speed is None else speed * 0.7 + instant * 0.3 + last_tick, last_done = now, done + progress = int(done * download_share / total) if total else 0 + if not total: + ok, free = free_space_ok(target.parent, None) + if not ok: + fail(f"На сервере закончилось место: свободно {human_size(free)}") + return + update_operation(operation, progress=min(progress, download_share), downloaded_bytes=done, speed_bps=int(speed), message=progress_message(done, total, speed)) + except Exception as exc: # noqa: BLE001 + fail(f"Загрузка прервалась: {describe_network_error(exc)}") + return + + if total is not None and done != total: + fail(f"Файл скачался не полностью: получено {human_size(done)} из {human_size(total)}") + return + elapsed = max(time.monotonic() - started, 0.001) + append_operation_log(operation_id, f"Скачано {human_size(done)} за {human_eta(elapsed) or '1 с'} ({human_size(done / elapsed)}/с).") + update_operation(operation, progress=download_share, downloaded_bytes=done, total_bytes=done if total is None else total, message="Файл скачан, проверяем…") + + if verify: + try: + expected = fetch_expected_checksum(operation["sums_url"], operation["filename"], operation_id) + except DownloadError as exc: + fail(str(exc)) + return + algo = operation.get("algo") or "sha256" + update_operation(operation, progress=97, message=f"Проверяем контрольную сумму ({algo})…") + actual = file_digest(part, algo) + if actual != expected: + append_operation_log(operation_id, f"Ожидали {algo}: {expected}") + append_operation_log(operation_id, f"Получили {algo}: {actual}") + fail("Контрольная сумма не совпала — файл повреждён или подменён, поэтому удалён. Попробуйте скачать ещё раз") + return + append_operation_log(operation_id, f"Контрольная сумма {algo} совпала.") + + if target.exists(): + fail(f"Файл {target.name} появился на сервере, пока шло скачивание. Скачанная копия удалена") + return + part.replace(target) + clear_cancel(operation_id) + append_operation_log(operation_id, f"Файл сохранён: {target}") + try: + result = after_download(operation, target, kind) + except Exception as exc: # noqa: BLE001 + finish_operation(operation, False, f"Файл скачан, но не удалось его подготовить: {str(exc)[:200]}", result_path=str(target)) + return + finish_operation(operation, True, result["message"], **result["meta"]) + + +def after_download(operation: dict[str, Any], target: Path, kind: str) -> dict[str, Any]: + """Что делать со скачанным файлом: обновить пул ISO либо распаковать и конвертировать диск.""" + operation_id = operation["id"] + if kind == "iso": + refresh = run_cmd(["virsh", "pool-refresh", ISO_POOL], timeout=20) + append_operation_log(operation_id, "Список установочных образов обновлён." if refresh["ok"] else f"Не удалось обновить пул {ISO_POOL}: {refresh.get('stderr') or refresh.get('stdout')}") + return {"message": f"Готово: {target.name} — можно создавать машину", "meta": {"result_path": str(target)}} + + path = target + if target.name.lower().endswith(".img.xz"): + update_operation(operation, progress=98, message=f"Распаковываем {target.name}…") + append_operation_log(operation_id, "Распаковываем сжатый образ…") + path = extract_xz_image(target) + target.unlink(missing_ok=True) + append_operation_log(operation_id, f"Распакован: {path}") + convert = start_convert(path) + meta = {"result_path": str(path)} + if convert: + meta["convert_operation_id"] = convert["id"] + append_operation_log(operation_id, f"Запущена конвертация в qcow2, задача {convert['id']}.") + return {"message": f"Готово: {path.name} скачан, конвертируем в формат qcow2", "meta": meta} + return {"message": f"Готово: {path.name} — можно создавать машину", "meta": meta} + + +def extract_xz_image(compressed: Path) -> Path: + import app # локальный импорт: app.py подключает этот модуль в самом конце + + return app.extract_xz_disk_image(compressed) + + +def start_convert(path: Path) -> dict[str, Any] | None: + import app # локальный импорт: app.py подключает этот модуль в самом конце + + return app.start_disk_convert_operation(path) + + +# ---------------------------------------------------------------- routes +def page_for(kind: str) -> str: + return KINDS.get(kind, KINDS["iso"])["page"] + + +@router.post("/downloads/url") +def download_from_url(request: Request, url: str = Form(""), kind: str = Form("iso")): + auth = require_auth(request) + if auth: + return auth + kind = kind if kind in KINDS else "iso" + try: + operation = start_download(url, kind) + except DownloadError as exc: + return redirect_with_message(page_for(kind), "download_error", str(exc)) + return redirect_with_message(page_for(kind), "download_message", f"Скачиваем {operation['filename']} — ход загрузки виден выше") + + +@router.post("/downloads/catalog/{image_id}") +def download_from_catalog(request: Request, image_id: str): + auth = require_auth(request) + if auth: + return auth + entry = catalog.cloud_image(image_id) + if not entry: + return redirect_with_message("/disk-images", "download_error", "Такого образа нет в каталоге") + arch = host_arch() + source = entry.get("arches", {}).get(arch) + if not source: + return redirect_with_message("/disk-images", "download_error", f"{entry['title']} не выпускается для процессора {arch}") + try: + operation = start_download(source["url"], "disk", title=f"Скачивание {entry['title']}", sums_url=source.get("sums"), algo=source.get("algo"), catalog_id=image_id) + except DownloadError as exc: + return redirect_with_message("/disk-images", "download_error", str(exc)) + return redirect_with_message("/disk-images", "download_message", f"Скачиваем {entry['title']} ({operation['filename']}) — после загрузки проверим контрольную сумму") + + +@router.post("/downloads/{operation_id}/cancel") +def cancel_download(request: Request, operation_id: str): + auth = require_auth(request) + if auth: + return auth + operation, error = request_cancel(operation_id) + page = page_for(str(operation.get("download_kind"))) if operation else "/iso" + if error: + return redirect_with_message(page, "download_error", error) + return redirect_with_message(page, "download_message", f"Останавливаем скачивание {operation.get('filename', '')}".rstrip()) + + +@router.post("/api/operations/{operation_id}/cancel") +def api_cancel_operation(request: Request, operation_id: str): + auth = require_api_auth(request) + if auth: + return auth + operation, error = request_cancel(operation_id) + if operation is None: + return JSONResponse({"ok": False, "error": error}, status_code=404) + if error: + return JSONResponse({"ok": False, "error": error}, status_code=400 if operation.get("type") != "download" else 409) + return {"ok": True, "operation": read_operation(operation_id) or operation} + + +# ---------------------------------------------------------------- templates +def register_template_globals() -> None: + """Функция downloads_panel нужна шаблонам iso.html и disk_images.html, которые + рендерит app.py своим окружением Jinja; регистрируем её и в core, и в app. + + Импорт app здесь циклический, но безопасный: app.py подключает этот модуль + в самом конце, когда его `templates` уже создан, а наш `router` — уже определён.""" + import app + + for env in {id(item): item for item in (core.templates.env, app.templates.env)}.values(): + env.globals["downloads_panel"] = downloads_panel + + +register_template_globals() diff --git a/web/presenters.py b/web/presenters.py index 89e525e..a8ec65e 100644 --- a/web/presenters.py +++ b/web/presenters.py @@ -242,3 +242,12 @@ def plural(count: int, one: str, few: str, many: str) -> str: def port_service(port: int) -> str: return WELL_KNOWN_PORTS.get(int(port or 0), "") + + +def format_bytes(value: int | float) -> str: + value = float(value or 0) + for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"): + if value < 1024 or unit == "ТБ": + return f"{value:.0f} {unit}" if value >= 10 or unit == "Б" else f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.0f} ТБ" diff --git a/web/static/app.css b/web/static/app.css index 33b98fa..7cd3560 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -584,3 +584,12 @@ details.advanced > .advanced-body { padding: 4px 16px 16px; } .hide-sm { display: none !important; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } + +/* ---------------------------------------------------------- downloads */ +.downloads { margin-bottom: 20px; } +.download-item .download-pct { flex: none; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; } +.download-item[data-status="error"] .download-pct { color: var(--danger); } +.download-item[data-status="success"] .download-pct { color: var(--success); } +.download-error { color: var(--danger); } +.catalog-item .meta code { padding: 1px 5px; border-radius: 5px; background: var(--surface-3); font: 12px/1.4 var(--mono); color: var(--text-2); } +@media (max-width: 640px) { .download-item, .catalog-item { flex-wrap: wrap; } .download-item .grow, .catalog-item .grow { flex-basis: calc(100% - 50px); } } diff --git a/web/templates/_downloads_active.html b/web/templates/_downloads_active.html new file mode 100644 index 0000000..0e8ecff --- /dev/null +++ b/web/templates/_downloads_active.html @@ -0,0 +1,71 @@ +{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk'). -#} +{% from "_ui.html" import icon %} +{% set dl = downloads_panel(kind) %} +{% if dl.active %} +
+
+

Скачиваем на сервер

Страницу можно закрыть — загрузка продолжится. Когда файл будет готов, он появится в списке.

+ {{ icon('list-checks', 'icon-sm') }}Все задачи +
+
+ {% for op in dl.active %} +
+ {{ icon('download') }} +
+
+
{{ op.filename }}
+ {{ op.progress|default(0) }}% +
+
+
{{ op.message }}
+
+ Подробнее +
+ +
+
+ {% endfor %} +
+
+ +{% endif %} diff --git a/web/templates/_downloads_catalog.html b/web/templates/_downloads_catalog.html new file mode 100644 index 0000000..842e353 --- /dev/null +++ b/web/templates/_downloads_catalog.html @@ -0,0 +1,36 @@ +{#- Карточка «Каталог готовых систем» для страницы /disk-images. -#} +{% from "_ui.html" import icon, badge %} +{% set dl = downloads_panel('disk') %} +
+
+

Каталог готовых систем

Официальные образы для вашего процессора. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.

+
+ {% if dl.catalog %} +
+ {% for item in dl.catalog %} +
+ {{ icon('circle-check' if item.status == 'ready' else 'download' if item.status == 'downloading' else 'box') }} +
+
+ {{ item.title }} + {% if item.recommended %}{{ badge('Рекомендуем', 'brand', dot=False) }}{% endif %} + {% if item.status == 'ready' %}{{ badge('Загружен', 'success') }}{% elif item.status == 'downloading' %}{{ badge('Скачивается', 'info') }}{% endif %} +
+
{{ item.hint }}{% if item.login %} · вход: {{ item.login }}{% endif %}
+
+ {% if item.status == 'ready' %} + {{ icon('plus', 'icon-sm') }}Создать машину + {% elif item.status == 'downloading' %} + Прогресс{{ icon('chevron-right', 'icon-sm') }} + {% else %} +
+ +
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

Для процессора этого сервера готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.

+ {% endif %} +
diff --git a/web/templates/_downloads_flash.html b/web/templates/_downloads_flash.html new file mode 100644 index 0000000..d8bb2a6 --- /dev/null +++ b/web/templates/_downloads_flash.html @@ -0,0 +1,4 @@ +{#- Сообщения после запуска/отмены скачивания (download_message / download_error в query string). -#} +{% from "_ui.html" import alert %} +{% if request.query_params.get('download_message') %}{{ alert('success', request.query_params.get('download_message')) }}{% endif %} +{% if request.query_params.get('download_error') %}{{ alert('danger', request.query_params.get('download_error')) }}{% endif %} diff --git a/web/templates/_downloads_url.html b/web/templates/_downloads_url.html new file mode 100644 index 0000000..47fde93 --- /dev/null +++ b/web/templates/_downloads_url.html @@ -0,0 +1,17 @@ +{#- Карточка «Скачать по ссылке». Ожидает переменную kind ('iso' | 'disk'). -#} +{% from "_ui.html" import icon %} +
+

Скачать по ссылке

Сервер скачает файл сам — не нужно гонять его через компьютер

+
+
+ +
+ + + {{ 'Ссылка должна вести на файл .iso — например, со страницы загрузки Ubuntu или Debian.' if kind == 'iso' else 'Подойдут файлы .qcow2, .img, .raw и сжатые .img.xz — распакуем и подготовим сами.' }} +
+
+
+
+
diff --git a/web/templates/disk_images.html b/web/templates/disk_images.html index 146a16b..b068b07 100644 --- a/web/templates/disk_images.html +++ b/web/templates/disk_images.html @@ -3,9 +3,14 @@ {% set page_title = 'Готовые системы' %} {% block page_header %}{% include "_images_header.html" %}{% endblock %} +{% block alerts %}{{ super() }}{% include "_downloads_flash.html" %}{% endblock %} {% block content %} +{% set kind = 'disk' %} +{% include "_downloads_active.html" %}
+
+ {% include "_downloads_catalog.html" %}

Готовые системы

Образы уже установленных систем — машина запускается сразу, без установки

{% if images %} @@ -22,9 +27,10 @@ {% endfor %}
{% else %} - {{ empty('hard-drive', 'Готовых образов пока нет', 'Подойдут облачные образы Ubuntu и Debian, образы для Raspberry Pi, диски других систем виртуализации.') }} + {{ empty('hard-drive', 'Готовых образов пока нет', 'Выберите систему в каталоге выше — или загрузите свой образ: облачные Ubuntu и Debian, образы для Raspberry Pi, диски других систем виртуализации.') }} {% endif %} +