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

- 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 удалений
Просмотреть файл
+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()