Скачивание образов по ссылке и из каталога готовых систем было добавлено
- 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
Этот коммит содержится в:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
@@ -0,0 +1,3 @@
|
||||
5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51 *resolute-server-cloudimg-amd64.img
|
||||
f69162950f235e3cdbbad33f1f912d1a504be90d8a37d002c735d6f3e3882265 *resolute-server-cloudimg-arm64.img
|
||||
e0361263a9f568c21a59e75c3b7810bc5867866e5bd48f499f8b2e6ab723f059 *resolute-server-cloudimg-amd64.vmdk
|
||||
Vendored
@@ -0,0 +1,3 @@
|
||||
# generated by debian cloud team
|
||||
1412d62c2d55ae2432df6d22c36b9edf4512a59a43bb4eadb1bd3a5ef201154a3052d83ffae704d7079b50388e61992013348e4bb4dee45b3c2a266e2b296135 debian-13-genericcloud-amd64.qcow2
|
||||
27c03aab0b5ee1f5918bfaa94821d2a1500fc171ffeb0ae159e7d18662f49171551f397683004434096e4006380fa607577b3c90fafa1eceb3a92702ebd49a15 debian-13-genericcloud-arm64.qcow2
|
||||
Vendored
@@ -0,0 +1,3 @@
|
||||
Capacity: 42949672960
|
||||
Allocation: 6442450944
|
||||
Physical: 6443499520
|
||||
@@ -0,0 +1,4 @@
|
||||
Type Device Target Source
|
||||
------------------------------------------------------------
|
||||
file disk vda /var/lib/virtuality/images/web01.qcow2
|
||||
file cdrom sda -
|
||||
Vendored
+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
|
||||
|
||||
Vendored
+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>
|
||||
Vendored
+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>
|
||||
@@ -0,0 +1 @@
|
||||
2ae30006299896d225c9d4de36e9a7c3d047c9b4a5793efe3a6422ec5040e8ee5f7d71326aa9922ffa446fbd11ed6cd1a8d0af4659b3d00233d3fea3550d2d78
|
||||
Vendored
+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
|
||||
}
|
||||
Vendored
@@ -0,0 +1,9 @@
|
||||
Name: clean
|
||||
Domain: web01
|
||||
Current: yes
|
||||
State: running
|
||||
Location: internal
|
||||
Parent: -
|
||||
Children: 1
|
||||
Descendants: 1
|
||||
Metadata: yes
|
||||
Vendored
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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} ТБ"
|
||||
|
||||
@@ -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); } }
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Ссылка в новой задаче
Block a user