Merge branch 'claude/downloads-review-fixes-smcxyr' into claude/production-system-image-smcxyr
Этот коммит содержится в:
+272
-20
@@ -2,8 +2,11 @@
|
|||||||
проверка ссылок, фоновая загрузка с локального http-сервера, отмена."""
|
проверка ссылок, фоновая загрузка с локального http-сервера, отмена."""
|
||||||
import hashlib
|
import hashlib
|
||||||
import lzma
|
import lzma
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -19,10 +22,12 @@ import features.downloads as downloads
|
|||||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||||
MB = 1024 * 1024
|
MB = 1024 * 1024
|
||||||
SLOW_TOTAL = 4 * MB
|
SLOW_TOTAL = 4 * MB
|
||||||
|
CHUNKED_TOTAL = 40 * 64 * 1024
|
||||||
|
|
||||||
|
|
||||||
class Handler(SimpleHTTPRequestHandler):
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
"""Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет."""
|
"""Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет,
|
||||||
|
/chunked.iso — 2,5 МБ с паузами без заголовка Content-Length (размер заранее неизвестен)."""
|
||||||
|
|
||||||
root = ""
|
root = ""
|
||||||
|
|
||||||
@@ -38,13 +43,15 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
self.send_header("Location", "/small.iso")
|
self.send_header("Location", "/small.iso")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
return
|
return
|
||||||
if self.path == "/slow.iso":
|
if self.path in ("/slow.iso", "/chunked.iso"):
|
||||||
|
total = SLOW_TOTAL if self.path == "/slow.iso" else CHUNKED_TOTAL
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/octet-stream")
|
self.send_header("Content-Type", "application/octet-stream")
|
||||||
self.send_header("Content-Length", str(SLOW_TOTAL))
|
if self.path == "/slow.iso":
|
||||||
|
self.send_header("Content-Length", str(total))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
try:
|
try:
|
||||||
for _ in range(SLOW_TOTAL // (64 * 1024)):
|
for _ in range(total // (64 * 1024)):
|
||||||
self.wfile.write(b"\0" * 64 * 1024)
|
self.wfile.write(b"\0" * 64 * 1024)
|
||||||
self.wfile.flush()
|
self.wfile.flush()
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
@@ -87,6 +94,18 @@ def wait_until(predicate, timeout: float = 10.0) -> None:
|
|||||||
raise AssertionError("условие не наступило вовремя")
|
raise AssertionError("условие не наступило вовремя")
|
||||||
|
|
||||||
|
|
||||||
|
def message_of(operation_id: str) -> str:
|
||||||
|
return str((core.read_operation(operation_id) or {}).get("message", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def seed_running_download(kind: str, filename: str, **meta) -> dict:
|
||||||
|
"""Операция «скачивается» без потока — как после перезапуска панели."""
|
||||||
|
target = downloads.target_path(kind, filename)
|
||||||
|
operation = core.new_operation("download", f"Скачивание {filename}", download_kind=kind, url=f"http://127.0.0.1:9/{filename}", filename=filename, target_path=str(target), cancel_requested=False, **meta)
|
||||||
|
core.update_operation(operation, status="running")
|
||||||
|
return operation
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- parsers & formatting
|
# ---------------------------------------------------------------- parsers & formatting
|
||||||
def test_parse_checksums_ubuntu_star_format():
|
def test_parse_checksums_ubuntu_star_format():
|
||||||
text = (FIXTURES / "SHA256SUMS").read_text()
|
text = (FIXTURES / "SHA256SUMS").read_text()
|
||||||
@@ -130,12 +149,35 @@ def test_progress_message():
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- validation
|
# ---------------------------------------------------------------- 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"])
|
@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", "http://example.org:abc/x.iso", "http://[::1/x.iso"])
|
||||||
def test_validate_url_rejects(url):
|
def test_validate_url_rejects(url):
|
||||||
with pytest.raises(downloads.DownloadError):
|
with pytest.raises(downloads.DownloadError):
|
||||||
downloads.validate_url(url)
|
downloads.validate_url(url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_url_handles_cyrillic_and_keeps_encoded_links():
|
||||||
|
encoded = downloads.encode_url("http://пример.рф:8080/папка/образ диска.iso?имя=ы#x")
|
||||||
|
assert encoded == "http://xn--e1afmkfd.xn--p1ai:8080/%D0%BF%D0%B0%D0%BF%D0%BA%D0%B0/%D0%BE%D0%B1%D1%80%D0%B0%D0%B7%20%D0%B4%D0%B8%D1%81%D0%BA%D0%B0.iso?%D0%B8%D0%BC%D1%8F=%D1%8B"
|
||||||
|
assert downloads.encode_url(encoded) == encoded
|
||||||
|
for same in ("https://user:pw@h.example/a%20b/x.iso?a=1&b=%2F", "http://[::1]:9/x.iso", "http://127.0.0.1/x.iso"):
|
||||||
|
assert downloads.encode_url(same) == same
|
||||||
|
assert downloads.validate_url("https://пример.рф/образ.iso") == "https://пример.рф/образ.iso"
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_network_error_is_friendly():
|
||||||
|
describe = downloads.describe_network_error
|
||||||
|
assert describe(urllib.error.HTTPError("u", 404, "Not Found", {}, None)) == "Файл по ссылке не найден (ошибка 404)"
|
||||||
|
assert describe(urllib.error.HTTPError("u", 403, "Forbidden", {}, None)) == "Сервер запретил скачивание (ошибка 403)"
|
||||||
|
assert describe(urllib.error.HTTPError("u", 418, "Teapot", {}, None)) == "Сервер ответил ошибкой 418"
|
||||||
|
assert describe(urllib.error.URLError(socket.gaierror(-2, "Name or service not known"))) == "Не удалось найти сервер по этой ссылке — проверьте адрес"
|
||||||
|
assert describe(urllib.error.URLError(ConnectionRefusedError(111, "Connection refused"))) == "Сервер отказал в подключении"
|
||||||
|
assert describe(urllib.error.URLError(ssl.SSLCertVerificationError("CERTIFICATE_VERIFY_FAILED"))) == "У сервера проблема с сертификатом безопасности"
|
||||||
|
assert describe(urllib.error.URLError(TimeoutError())).startswith("Сервер не отвечает")
|
||||||
|
assert describe(UnicodeEncodeError("ascii", "образ", 0, 5, "ordinal not in range(128)")) == "Ссылка содержит символы, которые сервер не понимает"
|
||||||
|
assert describe(ConnectionResetError(104, "reset")) == "Сервер разорвал соединение"
|
||||||
|
assert describe(RuntimeError("boom")) == "boom"
|
||||||
|
|
||||||
|
|
||||||
def test_filename_from_url_sanitises_and_checks_suffix():
|
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/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/dir/debian-13.qcow2", "disk") == "debian-13.qcow2"
|
||||||
@@ -152,6 +194,28 @@ def test_start_download_refuses_existing_file(data_dirs):
|
|||||||
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
|
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_download_refuses_when_extracted_image_exists(data_dirs):
|
||||||
|
# Из pi.img.xz после скачивания остаётся pi.img (или pi.qcow2 после конвертации) — это тот же образ.
|
||||||
|
(data_dirs["disk_images"] / "pi.img").write_bytes(b"x")
|
||||||
|
with pytest.raises(downloads.DownloadError, match="pi.img уже есть"):
|
||||||
|
downloads.start_download("http://127.0.0.1:9/pi.img.xz", "disk")
|
||||||
|
(data_dirs["disk_images"] / "pi.img").unlink()
|
||||||
|
(data_dirs["disk_images"] / "pi.qcow2").write_bytes(b"x")
|
||||||
|
with pytest.raises(downloads.DownloadError, match="pi.qcow2 уже есть"):
|
||||||
|
downloads.start_download("http://127.0.0.1:9/pi.img", "disk")
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_download_refuses_when_already_downloading(data_dirs, monkeypatch):
|
||||||
|
seed_running_download("iso", "small.iso")
|
||||||
|
with pytest.raises(downloads.DownloadError, match="уже скачивается"):
|
||||||
|
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
|
||||||
|
# проверка идёт по всем операциям, а не по последним 50
|
||||||
|
for index in range(60):
|
||||||
|
core.new_operation("vm_create", f"Создание VM {index}")
|
||||||
|
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):
|
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))
|
monkeypatch.setattr(downloads.shutil, "disk_usage", lambda path: SimpleNamespace(free=1000, total=1000, used=0))
|
||||||
with pytest.raises(downloads.DownloadError, match="мало места"):
|
with pytest.raises(downloads.DownloadError, match="мало места"):
|
||||||
@@ -178,6 +242,7 @@ def test_download_iso_success_refreshes_pool(data_dirs, www, monkeypatch):
|
|||||||
assert not (data_dirs["iso"] / ".small.iso.part").exists()
|
assert not (data_dirs["iso"] / ".small.iso.part").exists()
|
||||||
assert ["virsh", "pool-refresh", "virtuality-iso"] in calls
|
assert ["virsh", "pool-refresh", "virtuality-iso"] in calls
|
||||||
assert "Готово" in done["message"]
|
assert "Готово" in done["message"]
|
||||||
|
assert not downloads.is_live(operation["id"])
|
||||||
|
|
||||||
|
|
||||||
def test_download_follows_redirect(data_dirs, www):
|
def test_download_follows_redirect(data_dirs, www):
|
||||||
@@ -188,11 +253,22 @@ def test_download_follows_redirect(data_dirs, www):
|
|||||||
assert "Перенаправление" in done["log_tail"]
|
assert "Перенаправление" in done["log_tail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_with_cyrillic_file_name_in_url(data_dirs, www):
|
||||||
|
(www.root / "образ.iso").write_bytes(b"cyrillic" * 10)
|
||||||
|
operation = downloads.start_download(f"{www.url}/образ.iso", "iso")
|
||||||
|
assert operation["filename"] == "virtuality-iso.iso"
|
||||||
|
done = wait_for(operation["id"])
|
||||||
|
assert done["status"] == "success", done
|
||||||
|
assert (data_dirs["iso"] / "virtuality-iso.iso").read_bytes() == b"cyrillic" * 10
|
||||||
|
assert "Перенаправление" not in done["log_tail"]
|
||||||
|
|
||||||
|
|
||||||
def test_download_missing_file_fails(data_dirs, www):
|
def test_download_missing_file_fails(data_dirs, www):
|
||||||
operation = downloads.start_download(f"{www.url}/nope.iso", "iso")
|
operation = downloads.start_download(f"{www.url}/nope.iso", "iso")
|
||||||
done = wait_for(operation["id"])
|
done = wait_for(operation["id"])
|
||||||
assert done["status"] == "error"
|
assert done["status"] == "error"
|
||||||
assert "404" in done["message"]
|
assert done["message"] == "Файл по ссылке не найден (ошибка 404)"
|
||||||
|
assert "HTTP Error 404" in done["log_tail"] # исходный текст остаётся в журнале
|
||||||
assert not list(data_dirs["iso"].iterdir())
|
assert not list(data_dirs["iso"].iterdir())
|
||||||
|
|
||||||
|
|
||||||
@@ -212,6 +288,24 @@ def test_download_refuses_when_disk_fills_up(data_dirs, www, monkeypatch):
|
|||||||
assert not list(data_dirs["iso"].iterdir())
|
assert not list(data_dirs["iso"].iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_without_content_length_stops_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) < 3 else (False, 42)
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloads, "free_space_ok", fake)
|
||||||
|
operation = downloads.start_download(f"{www.url}/chunked.iso", "iso")
|
||||||
|
done = wait_for(operation["id"])
|
||||||
|
assert done["status"] == "error"
|
||||||
|
assert "закончилось место" in done["message"] and "42 Б" in done["message"]
|
||||||
|
assert done["total_bytes"] is None and seen == [None, None, None]
|
||||||
|
assert not (data_dirs["iso"] / ".chunked.iso.part").exists()
|
||||||
|
assert not list(data_dirs["iso"].iterdir())
|
||||||
|
|
||||||
|
|
||||||
def test_download_cancel(data_dirs, www, logged_in):
|
def test_download_cancel(data_dirs, www, logged_in):
|
||||||
operation = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
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: (core.read_operation(operation["id"]) or {}).get("status") == "running")
|
||||||
@@ -224,7 +318,77 @@ def test_download_cancel(data_dirs, www, logged_in):
|
|||||||
assert done["cancelled"] is True and done["cancel_requested"] is True
|
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.part").exists()
|
||||||
assert not (data_dirs["iso"] / "slow.iso").exists()
|
assert not (data_dirs["iso"] / "slow.iso").exists()
|
||||||
assert not downloads.cancel_requested(operation["id"])
|
assert not downloads.cancel_requested(operation["id"]) and not downloads.is_live(operation["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_flag_on_disk_is_honoured_and_not_overwritten(data_dirs, www):
|
||||||
|
# Отмену могла принять другая копия панели: поток видит флаг в JSON операции раз в секунду.
|
||||||
|
operation = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||||
|
wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists())
|
||||||
|
fresh = core.read_operation(operation["id"])
|
||||||
|
fresh.pop("log_tail", None)
|
||||||
|
core.update_operation(fresh, cancel_requested=True, message="Останавливаем скачивание…")
|
||||||
|
done = wait_for(operation["id"])
|
||||||
|
assert done["status"] == "error" and done["cancelled"] is True and done["cancel_requested"] is True
|
||||||
|
assert not (data_dirs["iso"] / ".slow.iso.part").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_download_of_same_file_does_not_touch_the_first(data_dirs, www):
|
||||||
|
first = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||||
|
part = data_dirs["iso"] / ".slow.iso.part"
|
||||||
|
wait_until(part.exists)
|
||||||
|
# Второй запуск, проскочивший проверку «уже скачивается» (например, из другого процесса): операцию создаём руками.
|
||||||
|
second = core.new_operation("download", "Скачивание slow.iso", download_kind="iso", url=f"{www.url}/slow.iso", filename="slow.iso", target_path=first["target_path"], cancel_requested=False)
|
||||||
|
core.update_operation(second, status="running")
|
||||||
|
downloads.download_worker(second)
|
||||||
|
done = core.read_operation(second["id"])
|
||||||
|
assert done["status"] == "error" and "уже скачивается" in done["message"]
|
||||||
|
assert part.exists() # чужой файл не тронут
|
||||||
|
assert wait_for(first["id"])["status"] == "success"
|
||||||
|
assert (data_dirs["iso"] / "slow.iso").stat().st_size == SLOW_TOTAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_leftover_part_file_without_owner_does_not_block_download(data_dirs, www):
|
||||||
|
part = data_dirs["iso"] / ".small.iso.part"
|
||||||
|
part.write_bytes(b"half of a crashed download")
|
||||||
|
operation = downloads.start_download(f"{www.url}/small.iso", "iso")
|
||||||
|
done = wait_for(operation["id"])
|
||||||
|
assert done["status"] == "success", done
|
||||||
|
assert (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000
|
||||||
|
assert not part.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_downloads_are_reaped_on_startup(data_dirs, www, monkeypatch):
|
||||||
|
stale = seed_running_download("iso", "small.iso")
|
||||||
|
part = downloads.part_path(Path(stale["target_path"]))
|
||||||
|
part.write_bytes(b"half")
|
||||||
|
(data_dirs["disk_images"] / ".orphan.img.part").write_bytes(b"x")
|
||||||
|
live = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||||
|
wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists())
|
||||||
|
|
||||||
|
assert downloads.reap_stale_downloads() == 1
|
||||||
|
closed = core.read_operation(stale["id"])
|
||||||
|
assert closed["status"] == "error" and closed["interrupted"] is True and "перезапущена" in closed["message"]
|
||||||
|
assert not part.exists() and not (data_dirs["disk_images"] / ".orphan.img.part").exists()
|
||||||
|
assert (data_dirs["iso"] / ".slow.iso.part").exists() # живое скачивание не трогаем
|
||||||
|
assert wait_for(live["id"])["status"] == "success"
|
||||||
|
# после закрытия зависшей операции тот же файл можно скачать снова
|
||||||
|
done = wait_for(downloads.start_download(f"{www.url}/small.iso", "iso")["id"])
|
||||||
|
assert done["status"] == "success" and (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000
|
||||||
|
assert downloads.close_stale_downloads() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_of_stale_download_finishes_it_immediately(data_dirs, logged_in):
|
||||||
|
stale = seed_running_download("disk", "y.qcow2")
|
||||||
|
part = downloads.part_path(Path(stale["target_path"]))
|
||||||
|
part.write_bytes(b"x")
|
||||||
|
response = logged_in.post(f"/api/operations/{stale['id']}/cancel")
|
||||||
|
operation = response.json()["operation"]
|
||||||
|
assert response.status_code == 200 and operation["status"] == "error" and operation["cancelled"] is True
|
||||||
|
assert operation["message"] == "Скачивание отменено" and operation["cancel_requested"] is True
|
||||||
|
assert not part.exists()
|
||||||
|
assert not downloads.cancel_requested(stale["id"])
|
||||||
|
assert logged_in.post(f"/api/operations/{stale['id']}/cancel").status_code == 409
|
||||||
|
|
||||||
|
|
||||||
def test_catalog_download_verifies_checksum(data_dirs, www):
|
def test_catalog_download_verifies_checksum(data_dirs, www):
|
||||||
@@ -258,6 +422,28 @@ def test_catalog_download_fails_without_sums_entry(data_dirs, www):
|
|||||||
assert not list(data_dirs["disk_images"].iterdir())
|
assert not list(data_dirs["disk_images"].iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_during_checksum_verification_removes_file(data_dirs, www, logged_in, monkeypatch):
|
||||||
|
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")
|
||||||
|
|
||||||
|
def slow_digest(path, algo, on_progress=None):
|
||||||
|
for _ in range(200):
|
||||||
|
time.sleep(0.05)
|
||||||
|
if on_progress:
|
||||||
|
on_progress(1)
|
||||||
|
return hashlib.sha256(payload).hexdigest()
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloads, "file_digest", slow_digest)
|
||||||
|
operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256")
|
||||||
|
wait_until(lambda: "Проверяем контрольную сумму" in message_of(operation["id"]))
|
||||||
|
assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200
|
||||||
|
done = wait_for(operation["id"])
|
||||||
|
assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено"
|
||||||
|
assert "во время проверки" in done["log_tail"]
|
||||||
|
assert not list(data_dirs["disk_images"].iterdir())
|
||||||
|
|
||||||
|
|
||||||
def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeypatch):
|
def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeypatch):
|
||||||
raw = bytes(range(256)) * 256
|
raw = bytes(range(256)) * 256
|
||||||
(www.root / "pi.img.xz").write_bytes(lzma.compress(raw))
|
(www.root / "pi.img.xz").write_bytes(lzma.compress(raw))
|
||||||
@@ -273,6 +459,55 @@ def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeyp
|
|||||||
assert "qcow2" in done["message"]
|
assert "qcow2" in done["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_during_unpack_removes_archive_and_partial_image(data_dirs, www, logged_in, monkeypatch):
|
||||||
|
raw = bytes(range(256)) * 4096 # 1 МБ, распаковка кусками по 16 КБ с паузой — успеваем отменить
|
||||||
|
(www.root / "pi.img.xz").write_bytes(lzma.compress(raw))
|
||||||
|
real = downloads.extract_xz_image
|
||||||
|
|
||||||
|
def slow_extract(compressed, should_stop=None):
|
||||||
|
def stop():
|
||||||
|
time.sleep(0.05)
|
||||||
|
return bool(should_stop and should_stop())
|
||||||
|
|
||||||
|
return real(compressed, stop)
|
||||||
|
|
||||||
|
started = []
|
||||||
|
monkeypatch.setattr(downloads, "extract_xz_image", slow_extract)
|
||||||
|
monkeypatch.setattr(downloads, "CHUNK_SIZE", 16 * 1024)
|
||||||
|
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path))
|
||||||
|
operation = downloads.start_download(f"{www.url}/pi.img.xz", "disk")
|
||||||
|
wait_until(lambda: "Распаковываем" in message_of(operation["id"]))
|
||||||
|
assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200
|
||||||
|
done = wait_for(operation["id"])
|
||||||
|
assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено"
|
||||||
|
assert "Распаковка остановлена" in done["log_tail"]
|
||||||
|
assert not list(data_dirs["disk_images"].iterdir()) and started == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_xz_archive_is_removed_and_can_be_downloaded_again(data_dirs, www, monkeypatch):
|
||||||
|
(www.root / "bad.img.xz").write_bytes(b"definitely not an xz archive" * 100)
|
||||||
|
started = []
|
||||||
|
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path))
|
||||||
|
done = wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"])
|
||||||
|
assert done["status"] == "error"
|
||||||
|
assert done["message"] == "Не удалось распаковать скачанный архив — файл повреждён и удалён. Попробуйте скачать ещё раз"
|
||||||
|
assert "Ошибка распаковки" in done["log_tail"]
|
||||||
|
assert not list(data_dirs["disk_images"].iterdir()) and started == []
|
||||||
|
# архив убран, поэтому повторное скачивание не упирается в «уже есть на сервере»
|
||||||
|
assert wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"])["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_xz_image_stops_and_cleans_up(data_dirs):
|
||||||
|
raw = bytes(range(256)) * 1024
|
||||||
|
compressed = data_dirs["disk_images"] / "pi.img.xz"
|
||||||
|
compressed.write_bytes(lzma.compress(raw))
|
||||||
|
assert downloads.extract_xz_image(compressed).read_bytes() == raw
|
||||||
|
(data_dirs["disk_images"] / "pi.img").unlink()
|
||||||
|
with pytest.raises(downloads.DownloadCancelled):
|
||||||
|
downloads.extract_xz_image(compressed, lambda: True)
|
||||||
|
assert sorted(item.name for item in data_dirs["disk_images"].iterdir()) == ["pi.img.xz"]
|
||||||
|
|
||||||
|
|
||||||
def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch):
|
def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch):
|
||||||
(www.root / "ready.qcow2").write_bytes(b"q" * 10)
|
(www.root / "ready.qcow2").write_bytes(b"q" * 10)
|
||||||
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None)
|
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None)
|
||||||
@@ -282,13 +517,17 @@ def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- routes
|
# ---------------------------------------------------------------- routes
|
||||||
def test_url_route_starts_download(logged_in, data_dirs, www):
|
def test_url_route_starts_download(logged_in, data_dirs, www, monkeypatch):
|
||||||
|
# The worker is started by hand below: a 15 KB file would otherwise finish before the checks.
|
||||||
|
pending = {}
|
||||||
|
monkeypatch.setattr(downloads, "run_operation", lambda operation, worker: pending.update(operation=operation, worker=worker))
|
||||||
response = logged_in.post("/downloads/url", data={"url": f"{www.url}/small.iso", "kind": "iso"}, follow_redirects=False)
|
response = logged_in.post("/downloads/url", data={"url": f"{www.url}/small.iso", "kind": "iso"}, follow_redirects=False)
|
||||||
assert response.status_code == 303
|
assert response.status_code == 303
|
||||||
location = unquote(response.headers["location"])
|
location = unquote(response.headers["location"])
|
||||||
assert location.startswith("/iso?download_message=") and "small.iso" in location
|
assert location.startswith("/iso?download_message=") and "small.iso" in location
|
||||||
running = downloads.active_downloads("iso")
|
running = downloads.active_downloads("iso")
|
||||||
assert len(running) == 1 and running[0]["filename"] == "small.iso"
|
assert len(running) == 1 and running[0]["filename"] == "small.iso"
|
||||||
|
core.run_operation(pending["operation"], pending["worker"])
|
||||||
assert wait_for(running[0]["id"])["status"] == "success"
|
assert wait_for(running[0]["id"])["status"] == "success"
|
||||||
assert (data_dirs["iso"] / "small.iso").exists()
|
assert (data_dirs["iso"] / "small.iso").exists()
|
||||||
|
|
||||||
@@ -299,7 +538,7 @@ def test_url_route_rejects_bad_links(logged_in, data_dirs):
|
|||||||
assert "download_error=" in response.headers["location"] and response.headers["location"].startswith("/iso?")
|
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)
|
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 response.headers["location"].startswith("/disk-images?download_error=")
|
||||||
assert not core.running_operations("download")
|
assert not core.running_operations("download") and not downloads.unfinished_downloads()
|
||||||
|
|
||||||
|
|
||||||
def test_url_route_requires_login(client):
|
def test_url_route_requires_login(client):
|
||||||
@@ -340,7 +579,7 @@ 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(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")
|
monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64")
|
||||||
location = unquote(logged_in.post("/downloads/catalog/only-arm", follow_redirects=False).headers["location"])
|
location = unquote(logged_in.post("/downloads/catalog/only-arm", follow_redirects=False).headers["location"])
|
||||||
assert "не выпускается для процессора x86_64" in location
|
assert "нет версии для этого сервера (x86_64)" in location
|
||||||
assert downloads.catalog_entries() == []
|
assert downloads.catalog_entries() == []
|
||||||
assert "готовых образов в каталоге нет" in logged_in.get("/disk-images").text
|
assert "готовых образов в каталоге нет" in logged_in.get("/disk-images").text
|
||||||
|
|
||||||
@@ -353,33 +592,46 @@ def test_cancel_api_rules(logged_in, data_dirs):
|
|||||||
finished = core.new_operation("download", "Скачивание x", download_kind="iso", filename="x.iso")
|
finished = core.new_operation("download", "Скачивание x", download_kind="iso", filename="x.iso")
|
||||||
core.finish_operation(finished, True, "Готово")
|
core.finish_operation(finished, True, "Готово")
|
||||||
assert logged_in.post(f"/api/operations/{finished['id']}/cancel").status_code == 409
|
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")
|
running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2")
|
||||||
core.update_operation(running, status="running")
|
core.update_operation(running, status="running")
|
||||||
response = logged_in.post(f"/api/operations/{running['id']}/cancel")
|
response = logged_in.post(f"/api/operations/{running['id']}/cancel")
|
||||||
assert response.status_code == 200 and response.json()["operation"]["cancel_requested"] is True
|
operation = response.json()["operation"]
|
||||||
assert downloads.cancel_requested(running["id"])
|
assert response.status_code == 200 and operation["cancel_requested"] is True and operation["status"] == "error" and operation["cancelled"] is True
|
||||||
downloads.clear_cancel(running["id"])
|
assert not downloads.cancel_requested(running["id"])
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_form_redirects_to_page(logged_in, data_dirs):
|
def test_cancel_form_redirects_to_page(logged_in, data_dirs, www):
|
||||||
running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2")
|
stale = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2")
|
||||||
core.update_operation(running, status="running")
|
core.update_operation(stale, status="running")
|
||||||
response = logged_in.post(f"/downloads/{running['id']}/cancel", follow_redirects=False)
|
response = logged_in.post(f"/downloads/{stale['id']}/cancel", follow_redirects=False)
|
||||||
assert response.status_code == 303
|
assert response.status_code == 303
|
||||||
assert unquote(response.headers["location"]).startswith("/disk-images?download_message=Останавливаем скачивание y.qcow2")
|
assert unquote(response.headers["location"]).startswith("/disk-images?download_message=Скачивание y.qcow2 отменено")
|
||||||
|
assert core.read_operation(stale["id"])["status"] == "error"
|
||||||
|
live = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||||
|
response = logged_in.post(f"/downloads/{live['id']}/cancel", follow_redirects=False)
|
||||||
|
assert unquote(response.headers["location"]).startswith("/iso?download_message=Останавливаем скачивание slow.iso")
|
||||||
|
assert wait_for(live["id"])["cancelled"] is True
|
||||||
assert unquote(logged_in.post("/downloads/nope/cancel", follow_redirects=False).headers["location"]).startswith("/iso?download_error=")
|
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):
|
def test_pages_show_download_cards(logged_in, data_dirs, monkeypatch):
|
||||||
|
reads = []
|
||||||
|
real = downloads.unfinished_downloads
|
||||||
|
monkeypatch.setattr(downloads, "unfinished_downloads", lambda: reads.append(1) or real())
|
||||||
iso_page = logged_in.get("/iso").text
|
iso_page = logged_in.get("/iso").text
|
||||||
assert "Скачать по ссылке" in iso_page and 'name="kind" value="iso"' in iso_page
|
assert "Скачать по ссылке" in iso_page and 'name="kind" value="iso"' in iso_page
|
||||||
assert "Скачиваем на сервер" not in iso_page
|
assert "Скачиваем на сервер" not in iso_page
|
||||||
|
assert "не нужно сначала загружать его на компьютер" in iso_page
|
||||||
disk_page = logged_in.get("/disk-images").text
|
disk_page = logged_in.get("/disk-images").text
|
||||||
assert "Каталог готовых систем" in disk_page and "Рекомендуем" in disk_page and 'name="kind" value="disk"' in disk_page
|
assert "Каталог готовых систем" in disk_page and "Рекомендуем" in disk_page and 'name="kind" value="disk"' in disk_page
|
||||||
|
assert "подходящие этому серверу" in disk_page
|
||||||
|
assert len(reads) == 2 # каталог операций читается один раз на страницу, а не в каждой вставке
|
||||||
running = core.new_operation("download", "Скачивание big.iso", download_kind="iso", filename="big.iso", url="https://example.org/big.iso", total_bytes=None)
|
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="Подключаемся к серверу…")
|
core.update_operation(running, status="running", progress=0, message="Подключаемся к серверу…")
|
||||||
iso_page = logged_in.get("/iso?download_message=Скачиваем").text
|
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 "Скачиваем на сервер" 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 f"/downloads/{running['id']}/cancel" in iso_page and "Скачиваем</span>" in iso_page
|
||||||
|
# отмена — не ошибка: без тоста при отправке формы, опрос показывает её как предупреждение
|
||||||
|
assert 'data-toast="Скачивание отменено"' not in iso_page and "op.cancelled" in iso_page
|
||||||
assert "big.iso" not in logged_in.get("/disk-images").text
|
assert "big.iso" not in logged_in.get("/disk-images").text
|
||||||
|
|||||||
@@ -7,15 +7,19 @@
|
|||||||
образов из каталога). Скачивание можно отменить — недокачанный файл удаляется.
|
образов из каталога). Скачивание можно отменить — недокачанный файл удаляется.
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
|
import lzma
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
from urllib.parse import unquote, urlsplit
|
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -34,7 +38,6 @@ from core import (
|
|||||||
require_auth,
|
require_auth,
|
||||||
run_cmd,
|
run_cmd,
|
||||||
run_operation,
|
run_operation,
|
||||||
running_operations,
|
|
||||||
update_operation,
|
update_operation,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,16 +55,44 @@ KINDS: dict[str, dict[str, Any]] = {
|
|||||||
"disk": {"suffixes": (".qcow2", ".img", ".raw", ".img.xz"), "page": "/disk-images", "label": "образ диска", "hint": "Подойдут файлы .qcow2, .img, .raw и сжатые .img.xz"},
|
"disk": {"suffixes": (".qcow2", ".img", ".raw", ".img.xz"), "page": "/disk-images", "label": "образ диска", "hint": "Подойдут файлы .qcow2, .img, .raw и сжатые .img.xz"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Отмена: идентификаторы операций, которые попросили остановить. Поток
|
# Состояние скачиваний этого процесса (под одним замком):
|
||||||
# загрузки проверяет множество между кусками файла.
|
# - CANCEL_REQUESTS — операции, которые попросили остановить; поток загрузки
|
||||||
CANCEL_LOCK = threading.Lock()
|
# проверяет множество между кусками файла, при проверке суммы и распаковке;
|
||||||
|
# - LIVE_DOWNLOADS — операция → путь к файлу для скачиваний, которыми владеет
|
||||||
|
# живой поток. Операция без потока (после перезапуска панели) — сирота: её
|
||||||
|
# закрывают сразу, ничего не ожидая.
|
||||||
|
# Множества живут в памяти одного процесса: панель запускается с одним воркером
|
||||||
|
# uvicorn. На случай нескольких процессов поток раз в секунду читает флаг
|
||||||
|
# cancel_requested и из JSON операции.
|
||||||
|
STATE_LOCK = threading.Lock()
|
||||||
CANCEL_REQUESTS: set[str] = set()
|
CANCEL_REQUESTS: set[str] = set()
|
||||||
|
LIVE_DOWNLOADS: dict[str, str] = {}
|
||||||
|
# Проверки «файл уже есть» / «уже скачивается» и создание операции идут под одним
|
||||||
|
# замком, чтобы два запуска одного файла не прошли проверку одновременно.
|
||||||
|
START_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
# Понятные объяснения частых ошибок HTTP; остальные коды показываем числом.
|
||||||
|
HTTP_ERRORS = {
|
||||||
|
401: "Сервер требует вход по паролю (ошибка 401)",
|
||||||
|
403: "Сервер запретил скачивание (ошибка 403)",
|
||||||
|
404: "Файл по ссылке не найден (ошибка 404)",
|
||||||
|
410: "Файл по ссылке больше не выдаётся (ошибка 410)",
|
||||||
|
429: "Сервер просит подождать: слишком много запросов (ошибка 429)",
|
||||||
|
500: "На сервере произошла ошибка (500)",
|
||||||
|
502: "Сервер временно недоступен (ошибка 502)",
|
||||||
|
503: "Сервер временно недоступен (ошибка 503)",
|
||||||
|
504: "Сервер временно недоступен (ошибка 504)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DownloadError(ValueError):
|
class DownloadError(ValueError):
|
||||||
"""Понятная пользователю ошибка проверки ссылки или файла."""
|
"""Понятная пользователю ошибка проверки ссылки или файла."""
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadCancelled(Exception):
|
||||||
|
"""Пользователь отменил скачивание во время проверки суммы или распаковки."""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- formatting
|
# ---------------------------------------------------------------- formatting
|
||||||
def human_size(value: float | int | None) -> str:
|
def human_size(value: float | int | None) -> str:
|
||||||
"""1234567 → «1,2 МБ». Десятичная запятая, как принято в русском."""
|
"""1234567 → «1,2 МБ». Десятичная запятая, как принято в русском."""
|
||||||
@@ -107,14 +138,40 @@ def validate_url(url: str) -> str:
|
|||||||
raise DownloadError("Вставьте ссылку на файл")
|
raise DownloadError("Вставьте ссылку на файл")
|
||||||
if len(url) > 2048 or any(ch.isspace() for ch in url):
|
if len(url) > 2048 or any(ch.isspace() for ch in url):
|
||||||
raise DownloadError("Ссылка выглядит некорректно")
|
raise DownloadError("Ссылка выглядит некорректно")
|
||||||
parts = urlsplit(url)
|
try:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise DownloadError("Ссылка выглядит некорректно") from exc
|
||||||
if parts.scheme.lower() not in ("http", "https"):
|
if parts.scheme.lower() not in ("http", "https"):
|
||||||
raise DownloadError("Ссылка должна начинаться с http:// или https://")
|
raise DownloadError("Ссылка должна начинаться с http:// или https://")
|
||||||
if not parts.hostname:
|
if not parts.hostname:
|
||||||
raise DownloadError("В ссылке не указан адрес сервера")
|
raise DownloadError("В ссылке не указан адрес сервера")
|
||||||
|
encode_url(url) # кириллица в домене или пути, порт — проверяем сразу, а не в фоне
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def encode_url(url: str) -> str:
|
||||||
|
"""Ссылка в виде, который понимает urllib: домен с кириллицей — в IDNA, не-ASCII
|
||||||
|
символы в пути и параметрах — в проценты. Уже закодированные ссылки не меняются."""
|
||||||
|
parts = urlsplit(url)
|
||||||
|
try:
|
||||||
|
port = parts.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise DownloadError("Ссылка выглядит некорректно") from exc
|
||||||
|
try:
|
||||||
|
host = (parts.hostname or "").encode("idna").decode("ascii")
|
||||||
|
except UnicodeError as exc:
|
||||||
|
raise DownloadError("Ссылка содержит символы, которые сервер не понимает") from exc
|
||||||
|
netloc = f"[{host}]" if ":" in host else host
|
||||||
|
if port:
|
||||||
|
netloc = f"{netloc}:{port}"
|
||||||
|
if "@" in parts.netloc:
|
||||||
|
netloc = f"{parts.netloc.rsplit('@', 1)[0]}@{netloc}"
|
||||||
|
path = quote(parts.path, safe="/%:@!$&'()*+,;=~")
|
||||||
|
query = quote(parts.query, safe="/%:@!$&'()*+,;=?~")
|
||||||
|
return urlunsplit((parts.scheme.lower(), netloc, path, query, ""))
|
||||||
|
|
||||||
|
|
||||||
def filename_from_url(url: str, kind: str) -> str:
|
def filename_from_url(url: str, kind: str) -> str:
|
||||||
"""Имя файла из ссылки, приведённое к безопасному виду (как при загрузке с компьютера)."""
|
"""Имя файла из ссылки, приведённое к безопасному виду (как при загрузке с компьютера)."""
|
||||||
if kind not in KINDS:
|
if kind not in KINDS:
|
||||||
@@ -205,6 +262,63 @@ def file_digest(path: Path, algo: str, on_progress: Any = None) -> str:
|
|||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- operations
|
||||||
|
def unfinished_downloads() -> list[dict[str, Any]]:
|
||||||
|
"""Незавершённые скачивания по всему каталогу операций, а не по последним 50,
|
||||||
|
как running_operations: зависшая старая операция иначе не попадёт в проверку."""
|
||||||
|
core.ensure_operations_dir()
|
||||||
|
found = []
|
||||||
|
for path in core.OPERATIONS_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
if data.get("type") == "download" and data.get("status") in ("queued", "running"):
|
||||||
|
found.append(data)
|
||||||
|
found.sort(key=lambda op: str(op.get("created_at") or ""))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def is_live(operation_id: str) -> bool:
|
||||||
|
with STATE_LOCK:
|
||||||
|
return operation_id in LIVE_DOWNLOADS
|
||||||
|
|
||||||
|
|
||||||
|
def release_download(operation_id: str) -> None:
|
||||||
|
with STATE_LOCK:
|
||||||
|
LIVE_DOWNLOADS.pop(operation_id, None)
|
||||||
|
CANCEL_REQUESTS.discard(operation_id)
|
||||||
|
|
||||||
|
|
||||||
|
def reap_stale_downloads() -> int:
|
||||||
|
"""Скачивания без живого потока (панель перезапускалась) закрываются, а их
|
||||||
|
недокачанные .part удаляются — иначе они лежали бы в каталоге образов вечно.
|
||||||
|
|
||||||
|
Операции всех типов закрывает core.interrupt_orphaned_operations() при старте;
|
||||||
|
здесь остаётся убрать файлы .part, которых та не касается. Возвращает число
|
||||||
|
закрытых операций."""
|
||||||
|
count = 0
|
||||||
|
for op in unfinished_downloads():
|
||||||
|
if is_live(op["id"]):
|
||||||
|
continue
|
||||||
|
message = "Скачивание прервано: панель была перезапущена. Запустите его заново"
|
||||||
|
if op.get("target_path"):
|
||||||
|
part_path(Path(op["target_path"])).unlink(missing_ok=True)
|
||||||
|
release_download(op["id"])
|
||||||
|
finish_operation(op, False, message, interrupted=True)
|
||||||
|
count += 1
|
||||||
|
with STATE_LOCK:
|
||||||
|
live_targets = set(LIVE_DOWNLOADS.values())
|
||||||
|
for directory in (core.ISO_DIR, core.DISK_IMAGES_DIR):
|
||||||
|
if not directory.is_dir():
|
||||||
|
continue
|
||||||
|
for part in directory.glob(".*.part"):
|
||||||
|
if str(part.with_name(part.name[1:-5]).resolve()) in live_targets:
|
||||||
|
continue
|
||||||
|
part.unlink(missing_ok=True)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- catalog
|
# ---------------------------------------------------------------- catalog
|
||||||
def host_arch() -> str:
|
def host_arch() -> str:
|
||||||
arch = str(host_profile.load_host_profile().get("arch") or "").lower()
|
arch = str(host_profile.load_host_profile().get("arch") or "").lower()
|
||||||
@@ -224,10 +338,12 @@ def image_candidates(filename: str) -> list[str]:
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
def catalog_entries(arch: str | None = None) -> list[dict[str, Any]]:
|
def catalog_entries(arch: str | None = None, active: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
|
||||||
"""Каталог готовых систем для процессора сервера с пометками «Загружен» / «Скачивается»."""
|
"""Каталог готовых систем для процессора сервера с пометками «Загружен» / «Скачивается».
|
||||||
|
Список идущих скачиваний можно передать готовым, чтобы не читать каталог операций дважды."""
|
||||||
arch = arch or host_arch()
|
arch = arch or host_arch()
|
||||||
active = {op.get("catalog_id"): op for op in running_operations("download") if op.get("catalog_id")}
|
ops = unfinished_downloads() if active is None else active
|
||||||
|
by_catalog_id = {op.get("catalog_id"): op for op in ops if op.get("catalog_id")}
|
||||||
entries: list[dict[str, Any]] = []
|
entries: list[dict[str, Any]] = []
|
||||||
for item in catalog.CLOUD_IMAGES:
|
for item in catalog.CLOUD_IMAGES:
|
||||||
source = item.get("arches", {}).get(arch)
|
source = item.get("arches", {}).get(arch)
|
||||||
@@ -238,7 +354,7 @@ def catalog_entries(arch: str | None = None) -> list[dict[str, Any]]:
|
|||||||
except DownloadError:
|
except DownloadError:
|
||||||
continue
|
continue
|
||||||
ready = next((name for name in reversed(image_candidates(filename)) if (core.DISK_IMAGES_DIR / name).exists()), None)
|
ready = next((name for name in reversed(image_candidates(filename)) if (core.DISK_IMAGES_DIR / name).exists()), None)
|
||||||
operation = active.get(item["id"])
|
operation = by_catalog_id.get(item["id"])
|
||||||
entries.append({
|
entries.append({
|
||||||
"id": item["id"], "title": item["title"], "vendor": item.get("vendor", ""), "hint": item.get("hint", ""),
|
"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,
|
"recommended": bool(item.get("recommended")), "login": item.get("login", ""), "filename": filename,
|
||||||
@@ -248,9 +364,9 @@ def catalog_entries(arch: str | None = None) -> list[dict[str, Any]]:
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def active_downloads(kind: str | None = None) -> list[dict[str, Any]]:
|
def active_downloads(kind: str | None = None, ops: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
|
||||||
result = []
|
result = []
|
||||||
for op in running_operations("download"):
|
for op in unfinished_downloads() if ops is None else ops:
|
||||||
if kind and op.get("download_kind") != kind:
|
if kind and op.get("download_kind") != kind:
|
||||||
continue
|
continue
|
||||||
op["indeterminate"] = not op.get("total_bytes") and op.get("status") == "running"
|
op["indeterminate"] = not op.get("total_bytes") and op.get("status") == "running"
|
||||||
@@ -259,24 +375,29 @@ def active_downloads(kind: str | None = None) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def downloads_panel(kind: str) -> dict[str, Any]:
|
def downloads_panel(kind: str) -> dict[str, Any]:
|
||||||
"""Данные для карточек скачивания на страницах /iso и /disk-images (глобальная функция шаблонов)."""
|
"""Данные для карточек скачивания на страницах /iso и /disk-images (глобальная функция шаблонов).
|
||||||
|
Каталог операций читается один раз на вызов; страница вызывает функцию один раз и передаёт результат во вставки."""
|
||||||
kind = kind if kind in KINDS else "iso"
|
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 []}
|
ops = unfinished_downloads()
|
||||||
|
return {"kind": kind, "page": KINDS[kind]["page"], "hint": KINDS[kind]["hint"], "active": active_downloads(kind, ops), "catalog": catalog_entries(active=ops) if kind == "disk" else []}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- cancel
|
# ---------------------------------------------------------------- cancel
|
||||||
def cancel_requested(operation_id: str) -> bool:
|
def cancel_requested(operation_id: str) -> bool:
|
||||||
with CANCEL_LOCK:
|
with STATE_LOCK:
|
||||||
return operation_id in CANCEL_REQUESTS
|
return operation_id in CANCEL_REQUESTS
|
||||||
|
|
||||||
|
|
||||||
def clear_cancel(operation_id: str) -> None:
|
def clear_cancel(operation_id: str) -> None:
|
||||||
with CANCEL_LOCK:
|
with STATE_LOCK:
|
||||||
CANCEL_REQUESTS.discard(operation_id)
|
CANCEL_REQUESTS.discard(operation_id)
|
||||||
|
|
||||||
|
|
||||||
def request_cancel(operation_id: str) -> tuple[dict[str, Any] | None, str | None]:
|
def request_cancel(operation_id: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||||
"""Попросить остановить скачивание. Возвращает (операция, ошибка)."""
|
"""Попросить остановить скачивание. Возвращает (операция, ошибка).
|
||||||
|
|
||||||
|
Если операцией не владеет ни один поток (осталась от перезапуска панели),
|
||||||
|
ждать некого — она закрывается сразу, а недокачанный файл удаляется."""
|
||||||
operation = read_operation(operation_id)
|
operation = read_operation(operation_id)
|
||||||
if not operation:
|
if not operation:
|
||||||
return None, "Задача не найдена"
|
return None, "Задача не найдена"
|
||||||
@@ -284,9 +405,17 @@ def request_cancel(operation_id: str) -> tuple[dict[str, Any] | None, str | None
|
|||||||
return operation, "Отменить можно только скачивание"
|
return operation, "Отменить можно только скачивание"
|
||||||
if operation.get("status") not in ("queued", "running"):
|
if operation.get("status") not in ("queued", "running"):
|
||||||
return operation, "Скачивание уже завершено"
|
return operation, "Скачивание уже завершено"
|
||||||
with CANCEL_LOCK:
|
|
||||||
CANCEL_REQUESTS.add(operation_id)
|
|
||||||
operation.pop("log_tail", None)
|
operation.pop("log_tail", None)
|
||||||
|
with STATE_LOCK:
|
||||||
|
live = operation_id in LIVE_DOWNLOADS
|
||||||
|
if live:
|
||||||
|
CANCEL_REQUESTS.add(operation_id)
|
||||||
|
if not live:
|
||||||
|
if operation.get("target_path"):
|
||||||
|
part_path(Path(operation["target_path"])).unlink(missing_ok=True)
|
||||||
|
append_operation_log(operation_id, "Пользователь отменил скачивание. Поток загрузки не найден (панель перезапускалась), недокачанный файл удалён.")
|
||||||
|
finish_operation(operation, False, "Скачивание отменено", cancelled=True, cancel_requested=True)
|
||||||
|
return read_operation(operation_id) or operation, None
|
||||||
update_operation(operation, cancel_requested=True, message="Останавливаем скачивание…")
|
update_operation(operation, cancel_requested=True, message="Останавливаем скачивание…")
|
||||||
append_operation_log(operation_id, "Пользователь попросил отменить скачивание.")
|
append_operation_log(operation_id, "Пользователь попросил отменить скачивание.")
|
||||||
return operation, None
|
return operation, None
|
||||||
@@ -300,22 +429,29 @@ def start_download(url: str, kind: str, *, title: str | None = None, sums_url: s
|
|||||||
url = validate_url(url)
|
url = validate_url(url)
|
||||||
filename = filename_from_url(url, kind)
|
filename = filename_from_url(url, kind)
|
||||||
target = target_path(kind, filename)
|
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:
|
if sums_url:
|
||||||
validate_url(sums_url)
|
validate_url(sums_url)
|
||||||
if algo not in hashlib.algorithms_available:
|
if algo not in hashlib.algorithms_available:
|
||||||
raise DownloadError("Неизвестный алгоритм контрольной суммы")
|
raise DownloadError("Неизвестный алгоритм контрольной суммы")
|
||||||
ok, free = free_space_ok(target.parent, None)
|
with START_LOCK:
|
||||||
if not ok:
|
# Образ мог остаться под другим именем: распакованный .img или qcow2 после конвертации.
|
||||||
raise DownloadError(f"На сервере мало места: свободно {human_size(free)}")
|
existing = next((name for name in image_candidates(filename) if (target.parent / name).exists()), None)
|
||||||
operation = new_operation(
|
if existing:
|
||||||
"download", title or f"Скачивание {filename}", message="Подключаемся к серверу…",
|
raise DownloadError(f"Файл {existing} уже есть на сервере. Удалите его, если нужно скачать заново")
|
||||||
download_kind=kind, url=url, filename=filename, target_path=str(target), source_path=str(target), page=KINDS[kind]["page"],
|
if any(op.get("target_path") == str(target) for op in unfinished_downloads()):
|
||||||
sums_url=sums_url, algo=algo, catalog_id=catalog_id, total_bytes=None, downloaded_bytes=0, cancel_requested=False,
|
raise DownloadError(f"Файл {filename} уже скачивается")
|
||||||
)
|
# Владельца у .part нет (проверили выше) — это остаток прерванной загрузки.
|
||||||
|
part_path(target).unlink(missing_ok=True)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
with STATE_LOCK:
|
||||||
|
LIVE_DOWNLOADS[operation["id"]] = str(target)
|
||||||
append_operation_log(operation["id"], f"Ссылка: {url}")
|
append_operation_log(operation["id"], f"Ссылка: {url}")
|
||||||
append_operation_log(operation["id"], f"Файл: {target}")
|
append_operation_log(operation["id"], f"Файл: {target}")
|
||||||
run_operation(operation, download_worker)
|
run_operation(operation, download_worker)
|
||||||
@@ -323,20 +459,34 @@ def start_download(url: str, kind: str, *, title: str | None = None, sums_url: s
|
|||||||
|
|
||||||
|
|
||||||
def open_url(url: str) -> Any:
|
def open_url(url: str) -> Any:
|
||||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "*/*"})
|
request = urllib.request.Request(encode_url(url), headers={"User-Agent": USER_AGENT, "Accept": "*/*"})
|
||||||
return urllib.request.urlopen(request, timeout=TIMEOUT) # noqa: S310 — схема проверена в validate_url
|
return urllib.request.urlopen(request, timeout=TIMEOUT) # noqa: S310 — схема проверена в validate_url
|
||||||
|
|
||||||
|
|
||||||
def describe_network_error(exc: Exception) -> str:
|
def describe_network_error(exc: Exception) -> str:
|
||||||
|
"""Ошибка сети словами для пользователя; исходный текст остаётся в журнале операции."""
|
||||||
if isinstance(exc, urllib.error.HTTPError):
|
if isinstance(exc, urllib.error.HTTPError):
|
||||||
return f"Сервер ответил ошибкой {exc.code} {exc.reason}"
|
return HTTP_ERRORS.get(exc.code, f"Сервер ответил ошибкой {exc.code}")
|
||||||
if isinstance(exc, urllib.error.URLError):
|
if isinstance(exc, urllib.error.URLError):
|
||||||
reason = exc.reason
|
reason = exc.reason
|
||||||
if isinstance(reason, TimeoutError) or "timed out" in str(reason).lower():
|
text = str(reason).lower()
|
||||||
|
if isinstance(reason, TimeoutError) or "timed out" in text:
|
||||||
return f"Сервер не отвечает (ждали {TIMEOUT} с)"
|
return f"Сервер не отвечает (ждали {TIMEOUT} с)"
|
||||||
|
if isinstance(reason, socket.gaierror) or "name or service not known" in text or "nodename nor servname" in text or "name resolution" in text:
|
||||||
|
return "Не удалось найти сервер по этой ссылке — проверьте адрес"
|
||||||
|
if isinstance(reason, ConnectionRefusedError) or "connection refused" in text:
|
||||||
|
return "Сервер отказал в подключении"
|
||||||
|
if isinstance(reason, ssl.SSLError) or "certificate" in text or "ssl" in text:
|
||||||
|
return "У сервера проблема с сертификатом безопасности"
|
||||||
|
if isinstance(reason, UnicodeError):
|
||||||
|
return "Ссылка содержит символы, которые сервер не понимает"
|
||||||
return f"Не удалось подключиться к серверу: {reason}"
|
return f"Не удалось подключиться к серверу: {reason}"
|
||||||
|
if isinstance(exc, UnicodeError):
|
||||||
|
return "Ссылка содержит символы, которые сервер не понимает"
|
||||||
if isinstance(exc, TimeoutError) or "timed out" in str(exc).lower():
|
if isinstance(exc, TimeoutError) or "timed out" in str(exc).lower():
|
||||||
return f"Сервер перестал отвечать (ждали {TIMEOUT} с)"
|
return f"Сервер перестал отвечать (ждали {TIMEOUT} с)"
|
||||||
|
if isinstance(exc, (ConnectionResetError, BrokenPipeError, ConnectionAbortedError)):
|
||||||
|
return "Сервер разорвал соединение"
|
||||||
return str(exc)[:240]
|
return str(exc)[:240]
|
||||||
|
|
||||||
|
|
||||||
@@ -346,6 +496,7 @@ def fetch_expected_checksum(sums_url: str, filename: str, operation_id: str) ->
|
|||||||
with open_url(sums_url) as response:
|
with open_url(sums_url) as response:
|
||||||
text = response.read(SUMS_MAX_BYTES).decode("utf-8", errors="replace")
|
text = response.read(SUMS_MAX_BYTES).decode("utf-8", errors="replace")
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
|
append_operation_log(operation_id, f"Ошибка: {exc}")
|
||||||
raise DownloadError(f"Не удалось скачать файл контрольных сумм: {describe_network_error(exc)}") from exc
|
raise DownloadError(f"Не удалось скачать файл контрольных сумм: {describe_network_error(exc)}") from exc
|
||||||
expected = parse_checksums(text, filename)
|
expected = parse_checksums(text, filename)
|
||||||
if not expected:
|
if not expected:
|
||||||
@@ -354,6 +505,13 @@ def fetch_expected_checksum(sums_url: str, filename: str, operation_id: str) ->
|
|||||||
|
|
||||||
|
|
||||||
def download_worker(operation: dict[str, Any]) -> None:
|
def download_worker(operation: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
run_download(operation)
|
||||||
|
finally:
|
||||||
|
release_download(operation["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def run_download(operation: dict[str, Any]) -> None:
|
||||||
operation_id = operation["id"]
|
operation_id = operation["id"]
|
||||||
kind = operation["download_kind"]
|
kind = operation["download_kind"]
|
||||||
target = Path(operation["target_path"])
|
target = Path(operation["target_path"])
|
||||||
@@ -364,27 +522,35 @@ def download_worker(operation: dict[str, Any]) -> None:
|
|||||||
def cancelled() -> bool:
|
def cancelled() -> bool:
|
||||||
return cancel_requested(operation_id)
|
return cancel_requested(operation_id)
|
||||||
|
|
||||||
def fail(message: str, **meta: Any) -> None:
|
def check_cancel(*_args: Any) -> None:
|
||||||
part.unlink(missing_ok=True)
|
if cancelled():
|
||||||
clear_cancel(operation_id)
|
raise DownloadCancelled
|
||||||
|
|
||||||
|
def fail(message: str, *, keep_part: bool = False, **meta: Any) -> None:
|
||||||
|
if not keep_part:
|
||||||
|
part.unlink(missing_ok=True)
|
||||||
finish_operation(operation, False, message, **meta)
|
finish_operation(operation, False, message, **meta)
|
||||||
|
|
||||||
def stop() -> None:
|
def stop(note: str = "Скачивание остановлено, недокачанный файл удалён.") -> None:
|
||||||
append_operation_log(operation_id, "Скачивание остановлено, недокачанный файл удалён.")
|
append_operation_log(operation_id, note)
|
||||||
fail("Скачивание отменено", cancelled=True, cancel_requested=True)
|
fail("Скачивание отменено", cancelled=True, cancel_requested=True)
|
||||||
|
|
||||||
|
def network_failure(prefix: str, exc: Exception) -> None:
|
||||||
|
append_operation_log(operation_id, f"Ошибка: {exc}")
|
||||||
|
fail(f"{prefix}{describe_network_error(exc)}")
|
||||||
|
|
||||||
if cancelled():
|
if cancelled():
|
||||||
stop()
|
stop()
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
response = open_url(operation["url"])
|
response = open_url(operation["url"])
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
fail(describe_network_error(exc))
|
network_failure("", exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
with response:
|
with response:
|
||||||
final_url = response.geturl()
|
final_url = response.geturl()
|
||||||
if final_url != operation["url"]:
|
if final_url != encode_url(operation["url"]):
|
||||||
append_operation_log(operation_id, f"Перенаправление: {final_url}")
|
append_operation_log(operation_id, f"Перенаправление: {final_url}")
|
||||||
length = response.headers.get("Content-Length")
|
length = response.headers.get("Content-Length")
|
||||||
total = int(length) if length and length.isdigit() else None
|
total = int(length) if length and length.isdigit() else None
|
||||||
@@ -401,7 +567,9 @@ def download_worker(operation: dict[str, Any]) -> None:
|
|||||||
last_done = 0
|
last_done = 0
|
||||||
speed: float | None = None
|
speed: float | None = None
|
||||||
try:
|
try:
|
||||||
with part.open("wb") as handle:
|
# Файл создаётся монопольно: если .part уже пишет другой поток, вторая загрузка
|
||||||
|
# не должна ни писать в него, ни удалять его при ошибке.
|
||||||
|
with part.open("xb") as handle:
|
||||||
while True:
|
while True:
|
||||||
if cancelled():
|
if cancelled():
|
||||||
stop()
|
stop()
|
||||||
@@ -413,6 +581,15 @@ def download_worker(operation: dict[str, Any]) -> None:
|
|||||||
done += len(chunk)
|
done += len(chunk)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - last_tick >= 1.0:
|
if now - last_tick >= 1.0:
|
||||||
|
# Раз в секунду перечитываем JSON операции: так запись прогресса не затирает
|
||||||
|
# флаг cancel_requested, который выставил request_cancel, а отмена из другого
|
||||||
|
# процесса панели (несколько воркеров uvicorn) тоже будет замечена.
|
||||||
|
fresh = read_operation(operation_id) or {}
|
||||||
|
fresh.pop("log_tail", None)
|
||||||
|
operation.update(fresh)
|
||||||
|
if cancelled() or operation.get("cancel_requested"):
|
||||||
|
stop()
|
||||||
|
return
|
||||||
instant = (done - last_done) / (now - last_tick)
|
instant = (done - last_done) / (now - last_tick)
|
||||||
speed = instant if speed is None else speed * 0.7 + instant * 0.3
|
speed = instant if speed is None else speed * 0.7 + instant * 0.3
|
||||||
last_tick, last_done = now, done
|
last_tick, last_done = now, done
|
||||||
@@ -423,8 +600,12 @@ def download_worker(operation: dict[str, Any]) -> None:
|
|||||||
fail(f"На сервере закончилось место: свободно {human_size(free)}")
|
fail(f"На сервере закончилось место: свободно {human_size(free)}")
|
||||||
return
|
return
|
||||||
update_operation(operation, progress=min(progress, download_share), downloaded_bytes=done, speed_bps=int(speed), message=progress_message(done, total, speed))
|
update_operation(operation, progress=min(progress, download_share), downloaded_bytes=done, speed_bps=int(speed), message=progress_message(done, total, speed))
|
||||||
|
except FileExistsError:
|
||||||
|
append_operation_log(operation_id, f"Файл {part.name} уже создан другой загрузкой — эта копия остановлена, чужой файл не тронут.")
|
||||||
|
fail(f"Файл {target.name} уже скачивается", keep_part=True)
|
||||||
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
fail(f"Загрузка прервалась: {describe_network_error(exc)}")
|
network_failure("Загрузка прервалась: ", exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
if total is not None and done != total:
|
if total is not None and done != total:
|
||||||
@@ -434,37 +615,48 @@ def download_worker(operation: dict[str, Any]) -> None:
|
|||||||
append_operation_log(operation_id, f"Скачано {human_size(done)} за {human_eta(elapsed) or '1 с'} ({human_size(done / elapsed)}/с).")
|
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="Файл скачан, проверяем…")
|
update_operation(operation, progress=download_share, downloaded_bytes=done, total_bytes=done if total is None else total, message="Файл скачан, проверяем…")
|
||||||
|
|
||||||
if verify:
|
try:
|
||||||
try:
|
if verify:
|
||||||
expected = fetch_expected_checksum(operation["sums_url"], operation["filename"], operation_id)
|
expected = fetch_expected_checksum(operation["sums_url"], operation["filename"], operation_id)
|
||||||
except DownloadError as exc:
|
check_cancel()
|
||||||
fail(str(exc))
|
algo = operation.get("algo") or "sha256"
|
||||||
return
|
update_operation(operation, progress=97, message=f"Проверяем контрольную сумму ({algo})…")
|
||||||
algo = operation.get("algo") or "sha256"
|
actual = file_digest(part, algo, on_progress=check_cancel)
|
||||||
update_operation(operation, progress=97, message=f"Проверяем контрольную сумму ({algo})…")
|
if actual != expected:
|
||||||
actual = file_digest(part, algo)
|
append_operation_log(operation_id, f"Ожидали {algo}: {expected}")
|
||||||
if actual != expected:
|
append_operation_log(operation_id, f"Получили {algo}: {actual}")
|
||||||
append_operation_log(operation_id, f"Ожидали {algo}: {expected}")
|
fail("Контрольная сумма не совпала — файл повреждён или подменён, поэтому удалён. Попробуйте скачать ещё раз")
|
||||||
append_operation_log(operation_id, f"Получили {algo}: {actual}")
|
return
|
||||||
fail("Контрольная сумма не совпала — файл повреждён или подменён, поэтому удалён. Попробуйте скачать ещё раз")
|
append_operation_log(operation_id, f"Контрольная сумма {algo} совпала.")
|
||||||
return
|
check_cancel()
|
||||||
append_operation_log(operation_id, f"Контрольная сумма {algo} совпала.")
|
except DownloadCancelled:
|
||||||
|
stop("Скачивание остановлено во время проверки, файл удалён.")
|
||||||
|
return
|
||||||
|
except DownloadError as exc:
|
||||||
|
fail(str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
if target.exists():
|
if target.exists():
|
||||||
fail(f"Файл {target.name} появился на сервере, пока шло скачивание. Скачанная копия удалена")
|
fail(f"Файл {target.name} появился на сервере, пока шло скачивание. Скачанная копия удалена")
|
||||||
return
|
return
|
||||||
part.replace(target)
|
part.replace(target)
|
||||||
clear_cancel(operation_id)
|
|
||||||
append_operation_log(operation_id, f"Файл сохранён: {target}")
|
append_operation_log(operation_id, f"Файл сохранён: {target}")
|
||||||
try:
|
try:
|
||||||
result = after_download(operation, target, kind)
|
result = after_download(operation, target, kind, cancelled)
|
||||||
|
except DownloadCancelled:
|
||||||
|
append_operation_log(operation_id, "Распаковка остановлена, скачанный архив и недораспакованный файл удалены.")
|
||||||
|
finish_operation(operation, False, "Скачивание отменено", cancelled=True, cancel_requested=True)
|
||||||
|
return
|
||||||
|
except DownloadError as exc:
|
||||||
|
finish_operation(operation, False, str(exc))
|
||||||
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
finish_operation(operation, False, f"Файл скачан, но не удалось его подготовить: {str(exc)[:200]}", result_path=str(target))
|
finish_operation(operation, False, f"Файл скачан, но не удалось его подготовить: {str(exc)[:200]}", result_path=str(target))
|
||||||
return
|
return
|
||||||
finish_operation(operation, True, result["message"], **result["meta"])
|
finish_operation(operation, True, result["message"], **result["meta"])
|
||||||
|
|
||||||
|
|
||||||
def after_download(operation: dict[str, Any], target: Path, kind: str) -> dict[str, Any]:
|
def after_download(operation: dict[str, Any], target: Path, kind: str, should_stop: Callable[[], bool] | None = None) -> dict[str, Any]:
|
||||||
"""Что делать со скачанным файлом: обновить пул ISO либо распаковать и конвертировать диск."""
|
"""Что делать со скачанным файлом: обновить пул ISO либо распаковать и конвертировать диск."""
|
||||||
operation_id = operation["id"]
|
operation_id = operation["id"]
|
||||||
if kind == "iso":
|
if kind == "iso":
|
||||||
@@ -476,7 +668,17 @@ def after_download(operation: dict[str, Any], target: Path, kind: str) -> dict[s
|
|||||||
if target.name.lower().endswith(".img.xz"):
|
if target.name.lower().endswith(".img.xz"):
|
||||||
update_operation(operation, progress=98, message=f"Распаковываем {target.name}…")
|
update_operation(operation, progress=98, message=f"Распаковываем {target.name}…")
|
||||||
append_operation_log(operation_id, "Распаковываем сжатый образ…")
|
append_operation_log(operation_id, "Распаковываем сжатый образ…")
|
||||||
path = extract_xz_image(target)
|
try:
|
||||||
|
path = extract_xz_image(target, should_stop)
|
||||||
|
except DownloadCancelled:
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# Битый архив со страницы не видно и не удалить (там только .img/.raw/.qcow2),
|
||||||
|
# а повторное скачивание он бы блокировал — убираем сами.
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
append_operation_log(operation_id, f"Ошибка распаковки: {exc}")
|
||||||
|
raise DownloadError("Не удалось распаковать скачанный архив — файл повреждён и удалён. Попробуйте скачать ещё раз") from exc
|
||||||
target.unlink(missing_ok=True)
|
target.unlink(missing_ok=True)
|
||||||
append_operation_log(operation_id, f"Распакован: {path}")
|
append_operation_log(operation_id, f"Распакован: {path}")
|
||||||
convert = start_convert(path)
|
convert = start_convert(path)
|
||||||
@@ -488,10 +690,25 @@ def after_download(operation: dict[str, Any], target: Path, kind: str) -> dict[s
|
|||||||
return {"message": f"Готово: {path.name} — можно создавать машину", "meta": meta}
|
return {"message": f"Готово: {path.name} — можно создавать машину", "meta": meta}
|
||||||
|
|
||||||
|
|
||||||
def extract_xz_image(compressed: Path) -> Path:
|
def extract_xz_image(compressed: Path, should_stop: Callable[[], bool] | None = None) -> Path:
|
||||||
|
"""Распаковать .img.xz рядом с архивом (имя подбирает app, как при загрузке с компьютера).
|
||||||
|
Между кусками проверяет should_stop: при отмене бросает DownloadCancelled, недораспакованный файл удаляется."""
|
||||||
import app # локальный импорт: app.py подключает этот модуль в самом конце
|
import app # локальный импорт: app.py подключает этот модуль в самом конце
|
||||||
|
|
||||||
return app.extract_xz_disk_image(compressed)
|
extracted = app.unique_disk_image_path(compressed.name[:-3])
|
||||||
|
try:
|
||||||
|
with lzma.open(compressed, "rb") as source, extracted.open("wb") as handle:
|
||||||
|
while True:
|
||||||
|
if should_stop and should_stop():
|
||||||
|
raise DownloadCancelled
|
||||||
|
chunk = source.read(CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
handle.write(chunk)
|
||||||
|
except BaseException:
|
||||||
|
extracted.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
return extracted
|
||||||
|
|
||||||
|
|
||||||
def start_convert(path: Path) -> dict[str, Any] | None:
|
def start_convert(path: Path) -> dict[str, Any] | None:
|
||||||
@@ -529,7 +746,7 @@ def download_from_catalog(request: Request, image_id: str):
|
|||||||
arch = host_arch()
|
arch = host_arch()
|
||||||
source = entry.get("arches", {}).get(arch)
|
source = entry.get("arches", {}).get(arch)
|
||||||
if not source:
|
if not source:
|
||||||
return redirect_with_message("/disk-images", "download_error", f"{entry['title']} не выпускается для процессора {arch}")
|
return redirect_with_message("/disk-images", "download_error", f"У {entry['title']} нет версии для этого сервера ({arch})")
|
||||||
try:
|
try:
|
||||||
operation = start_download(source["url"], "disk", title=f"Скачивание {entry['title']}", sums_url=source.get("sums"), algo=source.get("algo"), catalog_id=image_id)
|
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:
|
except DownloadError as exc:
|
||||||
@@ -546,7 +763,11 @@ def cancel_download(request: Request, operation_id: str):
|
|||||||
page = page_for(str(operation.get("download_kind"))) if operation else "/iso"
|
page = page_for(str(operation.get("download_kind"))) if operation else "/iso"
|
||||||
if error:
|
if error:
|
||||||
return redirect_with_message(page, "download_error", error)
|
return redirect_with_message(page, "download_error", error)
|
||||||
return redirect_with_message(page, "download_message", f"Останавливаем скачивание {operation.get('filename', '')}".rstrip())
|
filename = operation.get("filename", "")
|
||||||
|
if operation.get("status") == "error":
|
||||||
|
# Потока не было — операция уже закрыта, ждать нечего.
|
||||||
|
return redirect_with_message(page, "download_message", " ".join(word for word in ("Скачивание", filename, "отменено") if word))
|
||||||
|
return redirect_with_message(page, "download_message", f"Останавливаем скачивание {filename}".rstrip())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/operations/{operation_id}/cancel")
|
@router.post("/api/operations/{operation_id}/cancel")
|
||||||
@@ -562,6 +783,12 @@ def api_cancel_operation(request: Request, operation_id: str):
|
|||||||
return {"ok": True, "operation": read_operation(operation_id) or operation}
|
return {"ok": True, "operation": read_operation(operation_id) or operation}
|
||||||
|
|
||||||
|
|
||||||
|
@router.on_event("startup")
|
||||||
|
def close_stale_downloads() -> None:
|
||||||
|
"""После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part."""
|
||||||
|
reap_stale_downloads()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- templates
|
# ---------------------------------------------------------------- templates
|
||||||
def register_template_globals() -> None:
|
def register_template_globals() -> None:
|
||||||
"""Функция downloads_panel нужна шаблонам iso.html и disk_images.html, которые
|
"""Функция downloads_panel нужна шаблонам iso.html и disk_images.html, которые
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk'). -#}
|
{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk');
|
||||||
|
страница может заранее положить в dl результат downloads_panel(kind), чтобы не читать операции дважды. -#}
|
||||||
{% from "_ui.html" import icon %}
|
{% from "_ui.html" import icon %}
|
||||||
{% set dl = downloads_panel(kind) %}
|
{% set dl = dl if dl is defined else downloads_panel(kind) %}
|
||||||
{% if dl.active %}
|
{% if dl.active %}
|
||||||
<article class="card downloads" data-downloads>
|
<article class="card downloads" data-downloads>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
<div class="meta mt-1" data-dl="message">{{ op.message }}</div>
|
<div class="meta mt-1" data-dl="message">{{ op.message }}</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-sm btn-ghost hide-sm" href="/operations/{{ op.id }}">Подробнее</a>
|
<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">
|
{#- Без data-toast: сервер лишь просит поток остановиться, а итог («Скачивание отменено») покажет опрос ниже. -#}
|
||||||
|
<form method="post" action="/downloads/{{ op.id }}/cancel" class="inline-form" data-dl="cancel" data-confirm="Скачивание {{ op.filename }} остановится, а недокачанный файл будет удалён." data-confirm-title="Отменить скачивание?" data-confirm-button="Остановить">
|
||||||
<button class="btn btn-sm btn-danger-soft" {% if op.cancel_requested %}disabled{% endif %}>{{ icon('x', 'icon-sm') }}Отменить</button>
|
<button class="btn btn-sm btn-danger-soft" {% if op.cancel_requested %}disabled{% endif %}>{{ icon('x', 'icon-sm') }}Отменить</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,13 +56,19 @@
|
|||||||
if (op.status !== 'success' && op.status !== 'error') return;
|
if (op.status !== 'success' && op.status !== 'error') return;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
progress.classList.remove('indeterminate');
|
progress.classList.remove('indeterminate');
|
||||||
progress.classList.add(op.status === 'success' ? 'success' : 'danger');
|
|
||||||
cancel.hidden = true;
|
cancel.hidden = true;
|
||||||
item.dataset.status = op.status;
|
|
||||||
if (op.status === 'success') {
|
if (op.status === 'success') {
|
||||||
|
progress.classList.add('success');
|
||||||
|
item.dataset.status = op.status;
|
||||||
try { sessionStorage.setItem('virtualityToast', JSON.stringify({ message: op.message || 'Файл скачан', tone: 'success' })); } catch (_) {}
|
try { sessionStorage.setItem('virtualityToast', JSON.stringify({ message: op.message || 'Файл скачан', tone: 'success' })); } catch (_) {}
|
||||||
setTimeout(function () { location.replace(location.pathname); }, 900);
|
setTimeout(function () { location.replace(location.pathname); }, 900);
|
||||||
|
} else if (op.cancelled) {
|
||||||
|
// Отмена по просьбе пользователя — не ошибка: предупреждение, без красной строки и красного процента.
|
||||||
|
item.dataset.status = 'cancelled';
|
||||||
|
if (window.vToast) window.vToast(op.message || 'Скачивание отменено', 'warning');
|
||||||
} else {
|
} else {
|
||||||
|
progress.classList.add('danger');
|
||||||
|
item.dataset.status = op.status;
|
||||||
message.classList.add('download-error');
|
message.classList.add('download-error');
|
||||||
if (window.vToast) window.vToast(op.message || 'Скачивание не удалось', 'danger');
|
if (window.vToast) window.vToast(op.message || 'Скачивание не удалось', 'danger');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{#- Карточка «Каталог готовых систем» для страницы /disk-images. -#}
|
{#- Карточка «Каталог готовых систем» для страницы /disk-images; страница может заранее положить в dl результат downloads_panel('disk'). -#}
|
||||||
{% from "_ui.html" import icon, badge %}
|
{% from "_ui.html" import icon, badge %}
|
||||||
{% set dl = downloads_panel('disk') %}
|
{% set dl = dl if dl is defined else downloads_panel('disk') %}
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div><h2>Каталог готовых систем</h2><p>Официальные образы для вашего процессора. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.</p></div>
|
<div><h2>Каталог готовых систем</h2><p>Официальные образы, подходящие этому серверу. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.</p></div>
|
||||||
</div>
|
</div>
|
||||||
{% if dl.catalog %}
|
{% if dl.catalog %}
|
||||||
<div class="list">
|
<div class="list">
|
||||||
@@ -31,6 +31,6 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card-body"><p class="muted">Для процессора этого сервера готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.</p></div>
|
<div class="card-body"><p class="muted">Подходящих этому серверу готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.</p></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{#- Карточка «Скачать по ссылке». Ожидает переменную kind ('iso' | 'disk'). -#}
|
{#- Карточка «Скачать по ссылке». Ожидает переменную kind ('iso' | 'disk'). -#}
|
||||||
{% from "_ui.html" import icon %}
|
{% from "_ui.html" import icon %}
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-header"><div><h2>Скачать по ссылке</h2><p>Сервер скачает файл сам — не нужно гонять его через компьютер</p></div></div>
|
<div class="card-header"><div><h2>Скачать по ссылке</h2><p>Сервер скачает файл сам — не нужно сначала загружать его на компьютер</p></div></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="/downloads/url" class="stack-sm" data-busy="Проверяем ссылку…">
|
<form method="post" action="/downloads/url" class="stack-sm" data-busy="Проверяем ссылку…">
|
||||||
<input type="hidden" name="kind" value="{{ kind }}">
|
<input type="hidden" name="kind" value="{{ kind }}">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% set kind = 'disk' %}
|
{% set kind = 'disk' %}
|
||||||
|
{% set dl = downloads_panel(kind) %}
|
||||||
{% include "_downloads_active.html" %}
|
{% include "_downloads_active.html" %}
|
||||||
<section class="grid grid-main">
|
<section class="grid grid-main">
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% set kind = 'iso' %}
|
{% set kind = 'iso' %}
|
||||||
|
{% set dl = downloads_panel(kind) %}
|
||||||
{% include "_downloads_active.html" %}
|
{% include "_downloads_active.html" %}
|
||||||
<section class="grid grid-main">
|
<section class="grid grid-main">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user