Merge branch 'claude/downloads-review-fixes-smcxyr' into claude/production-system-image-smcxyr

Этот коммит содержится в:
Claude
2026-09-25 15:38:25 +00:00
родитель 01970f3071 c2dc32947f
Коммит a74e620192
7 изменённых файлов: 585 добавлений и 96 удалений
+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
+293 -66
Просмотреть файл
@@ -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, которые
+13 -5
Просмотреть файл
@@ -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');
} }
+4 -4
Просмотреть файл
@@ -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 -1
Просмотреть файл
@@ -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 }}">
+1
Просмотреть файл
@@ -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">
+1
Просмотреть файл
@@ -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">