Скачивание образов по ссылке и из каталога готовых систем было добавлено

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 14:49:43 +00:00
родитель 873b32c517
Коммит 4fd04be588
27 изменённых файлов: 1662 добавлений и 7 удалений
+17 -5
Просмотреть файл
@@ -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
+14 -1
Просмотреть файл
@@ -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
+3
Просмотреть файл
@@ -0,0 +1,3 @@
5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51 *resolute-server-cloudimg-amd64.img
f69162950f235e3cdbbad33f1f912d1a504be90d8a37d002c735d6f3e3882265 *resolute-server-cloudimg-arm64.img
e0361263a9f568c21a59e75c3b7810bc5867866e5bd48f499f8b2e6ab723f059 *resolute-server-cloudimg-amd64.vmdk
+3
Просмотреть файл
@@ -0,0 +1,3 @@
# generated by debian cloud team
1412d62c2d55ae2432df6d22c36b9edf4512a59a43bb4eadb1bd3a5ef201154a3052d83ffae704d7079b50388e61992013348e4bb4dee45b3c2a266e2b296135 debian-13-genericcloud-amd64.qcow2
27c03aab0b5ee1f5918bfaa94821d2a1500fc171ffeb0ae159e7d18662f49171551f397683004434096e4006380fa607577b3c90fafa1eceb3a92702ebd49a15 debian-13-genericcloud-arm64.qcow2
+3
Просмотреть файл
@@ -0,0 +1,3 @@
Capacity: 42949672960
Allocation: 6442450944
Physical: 6443499520
+4
Просмотреть файл
@@ -0,0 +1,4 @@
Type Device Target Source
------------------------------------------------------------
file disk vda /var/lib/virtuality/images/web01.qcow2
file cdrom sda -
+16
Просмотреть файл
@@ -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
+37
Просмотреть файл
@@ -0,0 +1,37 @@
<domain type='kvm' id='1'>
<name>web01</name>
<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>
<title>Сайт компании</title>
<description>Nginx + база. Бэкап по пятницам.</description>
<memory unit='KiB'>2097152</memory>
<currentMemory unit='KiB'>2097152</currentMemory>
<vcpu placement='static'>2</vcpu>
<os>
<type arch='x86_64' machine='pc-q35-10.2'>hvm</type>
<boot dev='hd'/>
</os>
<devices>
<emulator>/usr/bin/qemu-system-x86_64</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='/var/lib/virtuality/images/web01.qcow2'/>
<target dev='vda' bus='virtio'/>
</disk>
<disk type='file' device='cdrom'>
<driver name='qemu' type='raw'/>
<target dev='sda' bus='sata'/>
<readonly/>
</disk>
<interface type='network'>
<mac address='52:54:00:aa:bb:cc'/>
<source network='virtuality-nat'/>
<model type='virtio'/>
</interface>
<channel type='unix'>
<target type='virtio' name='org.qemu.guest_agent.0'/>
</channel>
<graphics type='vnc' port='5900' autoport='yes' listen='127.0.0.1'>
<listen type='address' address='127.0.0.1'/>
</graphics>
</devices>
</domain>
+17
Просмотреть файл
@@ -0,0 +1,17 @@
<network connections='2'>
<name>virtuality-nat</name>
<uuid>6c1b3c1e-1111-2222-3333-444444444444</uuid>
<forward mode='nat'>
<nat>
<port start='1024' end='65535'/>
</nat>
</forward>
<bridge name='virbr100' stp='on' delay='0'/>
<mac address='52:54:00:12:34:56'/>
<ip address='192.168.100.1' netmask='255.255.255.0'>
<dhcp>
<range start='192.168.100.50' end='192.168.100.200'/>
<host mac='52:54:00:aa:bb:cc' name='web01' ip='192.168.100.51'/>
</dhcp>
</ip>
</network>
+1
Просмотреть файл
@@ -0,0 +1 @@
2ae30006299896d225c9d4de36e9a7c3d047c9b4a5793efe3a6422ec5040e8ee5f7d71326aa9922ffa446fbd11ed6cd1a8d0af4659b3d00233d3fea3550d2d78
+10
Просмотреть файл
@@ -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
}
+9
Просмотреть файл
@@ -0,0 +1,9 @@
Name: clean
Domain: web01
Current: yes
State: running
Location: internal
Parent: -
Children: 1
Descendants: 1
Metadata: yes
+4
Просмотреть файл
@@ -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
+381
Просмотреть файл
@@ -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 "Скачиваем</span>" in iso_page
assert "big.iso" not in logged_in.get("/disk-images").text
+4
Просмотреть файл
@@ -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)
+82
Просмотреть файл
@@ -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]
+321
Просмотреть файл
@@ -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()
Просмотреть файл
+578
Просмотреть файл
@@ -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:
"""Хеш файла из файла контрольных сумм.
Понимает «<hash> *name» и «<hash> name» (Ubuntu, Debian), BSD-стиль
«SHA256 (name) = <hash>» и файл из одного хеша (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()
+9
Просмотреть файл
@@ -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} ТБ"
+9
Просмотреть файл
@@ -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); } }
+71
Просмотреть файл
@@ -0,0 +1,71 @@
{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk'). -#}
{% from "_ui.html" import icon %}
{% set dl = downloads_panel(kind) %}
{% if dl.active %}
<article class="card downloads" data-downloads>
<div class="card-header">
<div><h2>Скачиваем на сервер</h2><p>Страницу можно закрыть — загрузка продолжится. Когда файл будет готов, он появится в списке.</p></div>
<a class="btn btn-sm btn-ghost hide-sm" href="/operations">{{ icon('list-checks', 'icon-sm') }}Все задачи</a>
</div>
<div class="list">
{% for op in dl.active %}
<div class="list-item download-item" data-download-op="{{ op.id }}" data-status="{{ op.status }}">
<span class="list-icon info">{{ icon('download') }}</span>
<div class="grow">
<div class="row-between">
<div class="title truncate" title="{{ op.url }}">{{ op.filename }}</div>
<strong class="download-pct" data-dl="pct">{{ op.progress|default(0) }}%</strong>
</div>
<div class="progress mt-1{% if op.indeterminate %} indeterminate{% endif %}" data-dl="progress"><span data-dl="bar" style="width:{{ op.progress|default(0) }}%"></span></div>
<div class="meta mt-1" data-dl="message">{{ op.message }}</div>
</div>
<a class="btn btn-sm btn-ghost hide-sm" href="/operations/{{ op.id }}">Подробнее</a>
<form method="post" action="/downloads/{{ op.id }}/cancel" class="inline-form" data-dl="cancel" data-confirm="Скачивание {{ op.filename }} остановится, а недокачанный файл будет удалён." data-confirm-title="Отменить скачивание?" data-confirm-button="Остановить" data-toast="Скачивание отменено" data-toast-tone="warning">
<button class="btn btn-sm btn-danger-soft" {% if op.cancel_requested %}disabled{% endif %}>{{ icon('x', 'icon-sm') }}Отменить</button>
</form>
</div>
{% endfor %}
</div>
</article>
<script>
(function () {
var items = document.querySelectorAll('[data-download-op]');
if (!items.length) return;
items.forEach(function (item) {
var id = item.dataset.downloadOp;
var bar = item.querySelector('[data-dl="bar"]');
var pct = item.querySelector('[data-dl="pct"]');
var message = item.querySelector('[data-dl="message"]');
var progress = item.querySelector('[data-dl="progress"]');
var cancel = item.querySelector('[data-dl="cancel"]');
var timer = setInterval(async function () {
var op;
try {
var response = await fetch('/api/operations/' + encodeURIComponent(id), { cache: 'no-store', headers: { Accept: 'application/json' } });
if (!response.ok) return;
op = (await response.json()).operation || {};
} catch (_) { return; }
var value = Math.max(0, Math.min(100, Number(op.progress) || 0));
bar.style.width = value + '%';
pct.textContent = value + '%';
message.textContent = op.message || '';
if (op.total_bytes) progress.classList.remove('indeterminate');
if (op.cancel_requested) cancel.querySelector('button').disabled = true;
if (op.status !== 'success' && op.status !== 'error') return;
clearInterval(timer);
progress.classList.remove('indeterminate');
progress.classList.add(op.status === 'success' ? 'success' : 'danger');
cancel.hidden = true;
item.dataset.status = op.status;
if (op.status === 'success') {
try { sessionStorage.setItem('virtualityToast', JSON.stringify({ message: op.message || 'Файл скачан', tone: 'success' })); } catch (_) {}
setTimeout(function () { location.replace(location.pathname); }, 900);
} else {
message.classList.add('download-error');
if (window.vToast) window.vToast(op.message || 'Скачивание не удалось', 'danger');
}
}, 2000);
});
})();
</script>
{% endif %}
+36
Просмотреть файл
@@ -0,0 +1,36 @@
{#- Карточка «Каталог готовых систем» для страницы /disk-images. -#}
{% from "_ui.html" import icon, badge %}
{% set dl = downloads_panel('disk') %}
<article class="card">
<div class="card-header">
<div><h2>Каталог готовых систем</h2><p>Официальные образы для вашего процессора. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.</p></div>
</div>
{% if dl.catalog %}
<div class="list">
{% for item in dl.catalog %}
<div class="list-item catalog-item">
<span class="list-icon {{ 'success' if item.status == 'ready' else 'info' if item.status == 'downloading' else 'brand' }}">{{ icon('circle-check' if item.status == 'ready' else 'download' if item.status == 'downloading' else 'box') }}</span>
<div class="grow">
<div class="row" style="gap:8px">
<span class="title">{{ item.title }}</span>
{% if item.recommended %}{{ badge('Рекомендуем', 'brand', dot=False) }}{% endif %}
{% if item.status == 'ready' %}{{ badge('Загружен', 'success') }}{% elif item.status == 'downloading' %}{{ badge('Скачивается', 'info') }}{% endif %}
</div>
<div class="meta">{{ item.hint }}{% if item.login %} · вход: <code>{{ item.login }}</code>{% endif %}</div>
</div>
{% if item.status == 'ready' %}
<a class="btn btn-sm btn-primary" href="/vm/create?image={{ item.ready_file|urlencode }}">{{ icon('plus', 'icon-sm') }}Создать машину</a>
{% elif item.status == 'downloading' %}
<a class="btn btn-sm btn-ghost" href="/operations/{{ item.operation_id }}">Прогресс{{ icon('chevron-right', 'icon-sm') }}</a>
{% else %}
<form method="post" action="/downloads/catalog/{{ item.id }}" class="inline-form" data-busy="Запускаем…">
<button type="submit" class="btn btn-sm">{{ icon('download', 'icon-sm') }}Скачать</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="card-body"><p class="muted">Для процессора этого сервера готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.</p></div>
{% endif %}
</article>
+4
Просмотреть файл
@@ -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 %}
+17
Просмотреть файл
@@ -0,0 +1,17 @@
{#- Карточка «Скачать по ссылке». Ожидает переменную kind ('iso' | 'disk'). -#}
{% from "_ui.html" import icon %}
<article class="card">
<div class="card-header"><div><h2>Скачать по ссылке</h2><p>Сервер скачает файл сам — не нужно гонять его через компьютер</p></div></div>
<div class="card-body">
<form method="post" action="/downloads/url" class="stack-sm" data-busy="Проверяем ссылку…">
<input type="hidden" name="kind" value="{{ kind }}">
<div class="field">
<label for="download-url-{{ kind }}">Адрес файла</label>
<input class="input" id="download-url-{{ kind }}" type="url" name="url" inputmode="url" spellcheck="false" autocomplete="off" required
placeholder="{{ 'https://…/ubuntu-26.04-server.iso' if kind == 'iso' else 'https://…/debian-13-cloud.qcow2' }}">
<span class="hint">{{ 'Ссылка должна вести на файл .iso — например, со страницы загрузки Ubuntu или Debian.' if kind == 'iso' else 'Подойдут файлы .qcow2, .img, .raw и сжатые .img.xz — распакуем и подготовим сами.' }}</span>
</div>
<div class="row"><button type="submit" class="btn btn-primary">{{ icon('download') }}Скачать на сервер</button></div>
</form>
</div>
</article>
+8 -1
Просмотреть файл
@@ -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" %}
<section class="grid grid-main">
<div class="stack">
{% include "_downloads_catalog.html" %}
<article class="card">
<div class="card-header"><div><h2>Готовые системы</h2><p>Образы уже установленных систем — машина запускается сразу, без установки</p></div></div>
{% if images %}
@@ -22,9 +27,10 @@
{% endfor %}
</div>
{% else %}
{{ empty('hard-drive', 'Готовых образов пока нет', 'Подойдут облачные образы Ubuntu и Debian, образы для Raspberry Pi, диски других систем виртуализации.') }}
{{ empty('hard-drive', 'Готовых образов пока нет', 'Выберите систему в каталоге выше — или загрузите свой образ: облачные Ubuntu и Debian, образы для Raspberry Pi, диски других систем виртуализации.') }}
{% endif %}
</article>
</div>
<aside class="stack">
<article class="card">
@@ -33,6 +39,7 @@
{{ uploader('/disk-images/upload', 'image_file', '.qcow2,.img,.raw,.img.xz,.zip,.tar.gz,.tgz', 'Перетащите файл образа сюда', '.qcow2, .img, .raw, а также архивы .img.xz, .zip, .tar.gz', '/disk-images', 'Загрузить') }}
</div>
</article>
{% include "_downloads_url.html" %}
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('sparkles') }}<h3>Всё делается автоматически</h3></div>
+4
Просмотреть файл
@@ -3,8 +3,11 @@
{% set page_title = 'Образы ОС' %}
{% block page_header %}{% include "_images_header.html" %}{% endblock %}
{% block alerts %}{{ super() }}{% include "_downloads_flash.html" %}{% endblock %}
{% block content %}
{% set kind = 'iso' %}
{% include "_downloads_active.html" %}
<section class="grid grid-main">
<article class="card">
<div class="card-header">
@@ -36,6 +39,7 @@
{{ uploader('/iso/upload', 'iso_file', '.iso', 'Перетащите файл .iso сюда', 'или нажмите, чтобы выбрать на компьютере', '/iso', 'Загрузить') }}
</div>
</article>
{% include "_downloads_url.html" %}
<article class="card">
<div class="card-header"><h2>Где взять образ</h2></div>
<div class="list">