diff --git a/tests/test_downloads.py b/tests/test_downloads.py index fc1527d..e37941f 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -2,8 +2,11 @@ проверка ссылок, фоновая загрузка с локального http-сервера, отмена.""" import hashlib import lzma +import socket +import ssl import threading import time +import urllib.error from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from types import SimpleNamespace @@ -19,10 +22,12 @@ import features.downloads as downloads FIXTURES = Path(__file__).resolve().parent / "fixtures" MB = 1024 * 1024 SLOW_TOTAL = 4 * MB +CHUNKED_TOTAL = 40 * 64 * 1024 class Handler(SimpleHTTPRequestHandler): - """Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет.""" + """Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет, + /chunked.iso — 2,5 МБ с паузами без заголовка Content-Length (размер заранее неизвестен).""" root = "" @@ -38,13 +43,15 @@ class Handler(SimpleHTTPRequestHandler): self.send_header("Location", "/small.iso") self.end_headers() 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_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() try: - for _ in range(SLOW_TOTAL // (64 * 1024)): + for _ in range(total // (64 * 1024)): self.wfile.write(b"\0" * 64 * 1024) self.wfile.flush() time.sleep(0.05) @@ -87,6 +94,18 @@ def wait_until(predicate, timeout: float = 10.0) -> None: 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 def test_parse_checksums_ubuntu_star_format(): text = (FIXTURES / "SHA256SUMS").read_text() @@ -130,12 +149,35 @@ def test_progress_message(): # ---------------------------------------------------------------- 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): with pytest.raises(downloads.DownloadError): 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(): 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" @@ -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") +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): monkeypatch.setattr(downloads.shutil, "disk_usage", lambda path: SimpleNamespace(free=1000, total=1000, used=0)) 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 ["virsh", "pool-refresh", "virtuality-iso"] in calls assert "Готово" in done["message"] + assert not downloads.is_live(operation["id"]) 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"] +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): operation = downloads.start_download(f"{www.url}/nope.iso", "iso") done = wait_for(operation["id"]) assert done["status"] == "error" - assert "404" in done["message"] + assert done["message"] == "Файл по ссылке не найден (ошибка 404)" + assert "HTTP Error 404" in done["log_tail"] # исходный текст остаётся в журнале 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()) +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): operation = downloads.start_download(f"{www.url}/slow.iso", "iso") 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 not (data_dirs["iso"] / ".slow.iso.part").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): @@ -258,6 +422,28 @@ def test_catalog_download_fails_without_sums_entry(data_dirs, www): 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): raw = bytes(range(256)) * 256 (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"] +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): (www.root / "ready.qcow2").write_bytes(b"q" * 10) 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 -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) assert response.status_code == 303 location = unquote(response.headers["location"]) assert location.startswith("/iso?download_message=") and "small.iso" in location running = downloads.active_downloads("iso") assert len(running) == 1 and running[0]["filename"] == "small.iso" + core.run_operation(pending["operation"], pending["worker"]) assert wait_for(running[0]["id"])["status"] == "success" 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?") response = logged_in.post("/downloads/url", data={"url": "https://example.org/setup.exe", "kind": "disk"}, follow_redirects=False) assert response.headers["location"].startswith("/disk-images?download_error=") - assert not core.running_operations("download") + assert not core.running_operations("download") and not downloads.unfinished_downloads() 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(downloads, "host_arch", lambda: "x86_64") location = unquote(logged_in.post("/downloads/catalog/only-arm", follow_redirects=False).headers["location"]) - assert "не выпускается для процессора x86_64" in location + assert "нет версии для этого сервера (x86_64)" in location assert downloads.catalog_entries() == [] 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") core.finish_operation(finished, True, "Готово") assert logged_in.post(f"/api/operations/{finished['id']}/cancel").status_code == 409 + # операция без потока и без файла (панель перезапускалась) закрывается сразу running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") core.update_operation(running, status="running") response = logged_in.post(f"/api/operations/{running['id']}/cancel") - assert response.status_code == 200 and response.json()["operation"]["cancel_requested"] is True - assert downloads.cancel_requested(running["id"]) - downloads.clear_cancel(running["id"]) + operation = response.json()["operation"] + assert response.status_code == 200 and operation["cancel_requested"] is True and operation["status"] == "error" and operation["cancelled"] is True + assert not downloads.cancel_requested(running["id"]) -def test_cancel_form_redirects_to_page(logged_in, data_dirs): - running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") - core.update_operation(running, status="running") - response = logged_in.post(f"/downloads/{running['id']}/cancel", follow_redirects=False) +def test_cancel_form_redirects_to_page(logged_in, data_dirs, www): + stale = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") + core.update_operation(stale, status="running") + response = logged_in.post(f"/downloads/{stale['id']}/cancel", follow_redirects=False) 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=") - 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 assert "Скачать по ссылке" in iso_page and 'name="kind" value="iso"' in iso_page assert "Скачиваем на сервер" not in iso_page + assert "не нужно сначала загружать его на компьютер" in iso_page disk_page = logged_in.get("/disk-images").text assert "Каталог готовых систем" in disk_page and "Рекомендуем" in disk_page and 'name="kind" value="disk"' in disk_page + 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) core.update_operation(running, status="running", progress=0, message="Подключаемся к серверу…") iso_page = logged_in.get("/iso?download_message=Скачиваем").text assert "Скачиваем на сервер" in iso_page and "big.iso" in iso_page and "indeterminate" in iso_page assert f"/downloads/{running['id']}/cancel" in iso_page and "Скачиваем" 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 diff --git a/web/features/downloads.py b/web/features/downloads.py index 4c13ab3..1fc98c0 100644 --- a/web/features/downloads.py +++ b/web/features/downloads.py @@ -7,15 +7,19 @@ образов из каталога). Скачивание можно отменить — недокачанный файл удаляется. """ import hashlib +import json +import lzma import re import shutil +import socket +import ssl import threading import time import urllib.error import urllib.request from pathlib import Path -from typing import Any -from urllib.parse import unquote, urlsplit +from typing import Any, Callable +from urllib.parse import quote, unquote, urlsplit, urlunsplit from fastapi import APIRouter, Form, Request from fastapi.responses import JSONResponse @@ -34,7 +38,6 @@ from core import ( require_auth, run_cmd, run_operation, - running_operations, 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"}, } -# Отмена: идентификаторы операций, которые попросили остановить. Поток -# загрузки проверяет множество между кусками файла. -CANCEL_LOCK = threading.Lock() +# Состояние скачиваний этого процесса (под одним замком): +# - CANCEL_REQUESTS — операции, которые попросили остановить; поток загрузки +# проверяет множество между кусками файла, при проверке суммы и распаковке; +# - LIVE_DOWNLOADS — операция → путь к файлу для скачиваний, которыми владеет +# живой поток. Операция без потока (после перезапуска панели) — сирота: её +# закрывают сразу, ничего не ожидая. +# Множества живут в памяти одного процесса: панель запускается с одним воркером +# uvicorn. На случай нескольких процессов поток раз в секунду читает флаг +# cancel_requested и из JSON операции. +STATE_LOCK = threading.Lock() 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 DownloadCancelled(Exception): + """Пользователь отменил скачивание во время проверки суммы или распаковки.""" + + # ---------------------------------------------------------------- formatting def human_size(value: float | int | None) -> str: """1234567 → «1,2 МБ». Десятичная запятая, как принято в русском.""" @@ -107,14 +138,40 @@ def validate_url(url: str) -> str: raise DownloadError("Вставьте ссылку на файл") if len(url) > 2048 or any(ch.isspace() for ch in url): 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"): raise DownloadError("Ссылка должна начинаться с http:// или https://") if not parts.hostname: raise DownloadError("В ссылке не указан адрес сервера") + encode_url(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: """Имя файла из ссылки, приведённое к безопасному виду (как при загрузке с компьютера).""" if kind not in KINDS: @@ -205,6 +262,63 @@ def file_digest(path: Path, algo: str, on_progress: Any = None) -> str: 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 def host_arch() -> str: arch = str(host_profile.load_host_profile().get("arch") or "").lower() @@ -224,10 +338,12 @@ def image_candidates(filename: str) -> list[str]: 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() - 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]] = [] for item in catalog.CLOUD_IMAGES: source = item.get("arches", {}).get(arch) @@ -238,7 +354,7 @@ def catalog_entries(arch: str | None = None) -> list[dict[str, Any]]: except DownloadError: continue ready = next((name for name in reversed(image_candidates(filename)) if (core.DISK_IMAGES_DIR / name).exists()), None) - operation = active.get(item["id"]) + operation = by_catalog_id.get(item["id"]) entries.append({ "id": item["id"], "title": item["title"], "vendor": item.get("vendor", ""), "hint": item.get("hint", ""), "recommended": bool(item.get("recommended")), "login": item.get("login", ""), "filename": filename, @@ -248,9 +364,9 @@ def catalog_entries(arch: str | None = None) -> list[dict[str, Any]]: 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 = [] - 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: continue 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]: - """Данные для карточек скачивания на страницах /iso и /disk-images (глобальная функция шаблонов).""" + """Данные для карточек скачивания на страницах /iso и /disk-images (глобальная функция шаблонов). + Каталог операций читается один раз на вызов; страница вызывает функцию один раз и передаёт результат во вставки.""" kind = kind if kind in KINDS else "iso" - return {"kind": kind, "page": KINDS[kind]["page"], "hint": KINDS[kind]["hint"], "active": active_downloads(kind), "catalog": catalog_entries() if kind == "disk" else []} + 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 def cancel_requested(operation_id: str) -> bool: - with CANCEL_LOCK: + with STATE_LOCK: return operation_id in CANCEL_REQUESTS def clear_cancel(operation_id: str) -> None: - with CANCEL_LOCK: + with STATE_LOCK: CANCEL_REQUESTS.discard(operation_id) def request_cancel(operation_id: str) -> tuple[dict[str, Any] | None, str | None]: - """Попросить остановить скачивание. Возвращает (операция, ошибка).""" + """Попросить остановить скачивание. Возвращает (операция, ошибка). + + Если операцией не владеет ни один поток (осталась от перезапуска панели), + ждать некого — она закрывается сразу, а недокачанный файл удаляется.""" operation = read_operation(operation_id) if not operation: return None, "Задача не найдена" @@ -284,9 +405,17 @@ def request_cancel(operation_id: str) -> tuple[dict[str, Any] | None, str | None return operation, "Отменить можно только скачивание" if operation.get("status") not in ("queued", "running"): return operation, "Скачивание уже завершено" - with CANCEL_LOCK: - CANCEL_REQUESTS.add(operation_id) operation.pop("log_tail", None) + 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="Останавливаем скачивание…") append_operation_log(operation_id, "Пользователь попросил отменить скачивание.") 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) filename = filename_from_url(url, kind) target = target_path(kind, filename) - if target.exists(): - raise DownloadError(f"Файл {filename} уже есть на сервере. Удалите его, если нужно скачать заново") - if any(op.get("target_path") == str(target) for op in running_operations("download")): - raise DownloadError(f"Файл {filename} уже скачивается") if sums_url: validate_url(sums_url) if algo not in hashlib.algorithms_available: raise DownloadError("Неизвестный алгоритм контрольной суммы") - ok, free = free_space_ok(target.parent, None) - if not ok: - raise DownloadError(f"На сервере мало места: свободно {human_size(free)}") - operation = new_operation( - "download", title or f"Скачивание {filename}", message="Подключаемся к серверу…", - download_kind=kind, url=url, filename=filename, target_path=str(target), source_path=str(target), page=KINDS[kind]["page"], - sums_url=sums_url, algo=algo, catalog_id=catalog_id, total_bytes=None, downloaded_bytes=0, cancel_requested=False, - ) + with START_LOCK: + # Образ мог остаться под другим именем: распакованный .img или qcow2 после конвертации. + existing = next((name for name in image_candidates(filename) if (target.parent / name).exists()), None) + if existing: + raise DownloadError(f"Файл {existing} уже есть на сервере. Удалите его, если нужно скачать заново") + if any(op.get("target_path") == str(target) for op in unfinished_downloads()): + 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"Файл: {target}") 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: - 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 def describe_network_error(exc: Exception) -> str: + """Ошибка сети словами для пользователя; исходный текст остаётся в журнале операции.""" 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): 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} с)" + 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}" + if isinstance(exc, UnicodeError): + return "Ссылка содержит символы, которые сервер не понимает" if isinstance(exc, TimeoutError) or "timed out" in str(exc).lower(): return f"Сервер перестал отвечать (ждали {TIMEOUT} с)" + if isinstance(exc, (ConnectionResetError, BrokenPipeError, ConnectionAbortedError)): + return "Сервер разорвал соединение" 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: text = response.read(SUMS_MAX_BYTES).decode("utf-8", errors="replace") except Exception as exc: # noqa: BLE001 + append_operation_log(operation_id, f"Ошибка: {exc}") raise DownloadError(f"Не удалось скачать файл контрольных сумм: {describe_network_error(exc)}") from exc expected = parse_checksums(text, filename) 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: + try: + run_download(operation) + finally: + release_download(operation["id"]) + + +def run_download(operation: dict[str, Any]) -> None: operation_id = operation["id"] kind = operation["download_kind"] target = Path(operation["target_path"]) @@ -364,27 +522,35 @@ def download_worker(operation: dict[str, Any]) -> None: def cancelled() -> bool: return cancel_requested(operation_id) - def fail(message: str, **meta: Any) -> None: - part.unlink(missing_ok=True) - clear_cancel(operation_id) + def check_cancel(*_args: Any) -> None: + if cancelled(): + 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) - def stop() -> None: - append_operation_log(operation_id, "Скачивание остановлено, недокачанный файл удалён.") + def stop(note: str = "Скачивание остановлено, недокачанный файл удалён.") -> None: + append_operation_log(operation_id, note) 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(): stop() return try: response = open_url(operation["url"]) except Exception as exc: # noqa: BLE001 - fail(describe_network_error(exc)) + network_failure("", exc) return with response: final_url = response.geturl() - if final_url != operation["url"]: + if final_url != encode_url(operation["url"]): append_operation_log(operation_id, f"Перенаправление: {final_url}") length = response.headers.get("Content-Length") total = int(length) if length and length.isdigit() else None @@ -401,7 +567,9 @@ def download_worker(operation: dict[str, Any]) -> None: last_done = 0 speed: float | None = None try: - with part.open("wb") as handle: + # Файл создаётся монопольно: если .part уже пишет другой поток, вторая загрузка + # не должна ни писать в него, ни удалять его при ошибке. + with part.open("xb") as handle: while True: if cancelled(): stop() @@ -413,6 +581,15 @@ def download_worker(operation: dict[str, Any]) -> None: done += len(chunk) now = time.monotonic() 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) speed = instant if speed is None else speed * 0.7 + instant * 0.3 last_tick, last_done = now, done @@ -423,8 +600,12 @@ def download_worker(operation: dict[str, Any]) -> None: fail(f"На сервере закончилось место: свободно {human_size(free)}") return update_operation(operation, progress=min(progress, download_share), downloaded_bytes=done, speed_bps=int(speed), message=progress_message(done, total, speed)) + except FileExistsError: + append_operation_log(operation_id, f"Файл {part.name} уже создан другой загрузкой — эта копия остановлена, чужой файл не тронут.") + fail(f"Файл {target.name} уже скачивается", keep_part=True) + return except Exception as exc: # noqa: BLE001 - fail(f"Загрузка прервалась: {describe_network_error(exc)}") + network_failure("Загрузка прервалась: ", exc) return 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)}/с).") 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) - except DownloadError as exc: - fail(str(exc)) - return - algo = operation.get("algo") or "sha256" - update_operation(operation, progress=97, message=f"Проверяем контрольную сумму ({algo})…") - actual = file_digest(part, algo) - if actual != expected: - append_operation_log(operation_id, f"Ожидали {algo}: {expected}") - append_operation_log(operation_id, f"Получили {algo}: {actual}") - fail("Контрольная сумма не совпала — файл повреждён или подменён, поэтому удалён. Попробуйте скачать ещё раз") - return - append_operation_log(operation_id, f"Контрольная сумма {algo} совпала.") + check_cancel() + algo = operation.get("algo") or "sha256" + update_operation(operation, progress=97, message=f"Проверяем контрольную сумму ({algo})…") + actual = file_digest(part, algo, on_progress=check_cancel) + if actual != expected: + append_operation_log(operation_id, f"Ожидали {algo}: {expected}") + append_operation_log(operation_id, f"Получили {algo}: {actual}") + fail("Контрольная сумма не совпала — файл повреждён или подменён, поэтому удалён. Попробуйте скачать ещё раз") + return + append_operation_log(operation_id, f"Контрольная сумма {algo} совпала.") + check_cancel() + except DownloadCancelled: + stop("Скачивание остановлено во время проверки, файл удалён.") + return + except DownloadError as exc: + fail(str(exc)) + return if target.exists(): fail(f"Файл {target.name} появился на сервере, пока шло скачивание. Скачанная копия удалена") return part.replace(target) - clear_cancel(operation_id) append_operation_log(operation_id, f"Файл сохранён: {target}") try: - result = after_download(operation, target, kind) + 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 finish_operation(operation, False, f"Файл скачан, но не удалось его подготовить: {str(exc)[:200]}", result_path=str(target)) return finish_operation(operation, True, result["message"], **result["meta"]) -def after_download(operation: dict[str, Any], target: Path, kind: str) -> dict[str, Any]: +def after_download(operation: dict[str, Any], target: Path, kind: str, should_stop: Callable[[], bool] | None = None) -> dict[str, Any]: """Что делать со скачанным файлом: обновить пул ISO либо распаковать и конвертировать диск.""" operation_id = operation["id"] 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"): update_operation(operation, progress=98, message=f"Распаковываем {target.name}…") 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) append_operation_log(operation_id, f"Распакован: {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} -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 подключает этот модуль в самом конце - 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: @@ -529,7 +746,7 @@ def download_from_catalog(request: Request, image_id: str): arch = host_arch() source = entry.get("arches", {}).get(arch) 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: 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: @@ -546,7 +763,11 @@ def cancel_download(request: Request, operation_id: str): page = page_for(str(operation.get("download_kind"))) if operation else "/iso" if error: return redirect_with_message(page, "download_error", error) - return redirect_with_message(page, "download_message", f"Останавливаем скачивание {operation.get('filename', '')}".rstrip()) + 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") @@ -562,6 +783,12 @@ def api_cancel_operation(request: Request, operation_id: str): return {"ok": True, "operation": read_operation(operation_id) or operation} +@router.on_event("startup") +def close_stale_downloads() -> None: + """После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part.""" + reap_stale_downloads() + + # ---------------------------------------------------------------- templates def register_template_globals() -> None: """Функция downloads_panel нужна шаблонам iso.html и disk_images.html, которые diff --git a/web/templates/_downloads_active.html b/web/templates/_downloads_active.html index 0e8ecff..3fd1b62 100644 --- a/web/templates/_downloads_active.html +++ b/web/templates/_downloads_active.html @@ -1,6 +1,7 @@ -{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk'). -#} +{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk'); + страница может заранее положить в dl результат downloads_panel(kind), чтобы не читать операции дважды. -#} {% from "_ui.html" import icon %} -{% set dl = downloads_panel(kind) %} +{% set dl = dl if dl is defined else downloads_panel(kind) %} {% if dl.active %}
@@ -20,7 +21,8 @@
{{ op.message }}
Подробнее -
+ {#- Без data-toast: сервер лишь просит поток остановиться, а итог («Скачивание отменено») покажет опрос ниже. -#} +
@@ -54,13 +56,19 @@ if (op.status !== 'success' && op.status !== 'error') return; clearInterval(timer); progress.classList.remove('indeterminate'); - progress.classList.add(op.status === 'success' ? 'success' : 'danger'); cancel.hidden = true; - item.dataset.status = op.status; if (op.status === 'success') { + progress.classList.add('success'); + item.dataset.status = op.status; try { sessionStorage.setItem('virtualityToast', JSON.stringify({ message: op.message || 'Файл скачан', tone: 'success' })); } catch (_) {} setTimeout(function () { location.replace(location.pathname); }, 900); + } else if (op.cancelled) { + // Отмена по просьбе пользователя — не ошибка: предупреждение, без красной строки и красного процента. + item.dataset.status = 'cancelled'; + if (window.vToast) window.vToast(op.message || 'Скачивание отменено', 'warning'); } else { + progress.classList.add('danger'); + item.dataset.status = op.status; message.classList.add('download-error'); if (window.vToast) window.vToast(op.message || 'Скачивание не удалось', 'danger'); } diff --git a/web/templates/_downloads_catalog.html b/web/templates/_downloads_catalog.html index 842e353..61bab65 100644 --- a/web/templates/_downloads_catalog.html +++ b/web/templates/_downloads_catalog.html @@ -1,9 +1,9 @@ -{#- Карточка «Каталог готовых систем» для страницы /disk-images. -#} +{#- Карточка «Каталог готовых систем» для страницы /disk-images; страница может заранее положить в dl результат downloads_panel('disk'). -#} {% from "_ui.html" import icon, badge %} -{% set dl = downloads_panel('disk') %} +{% set dl = dl if dl is defined else downloads_panel('disk') %}
-

Каталог готовых систем

Официальные образы для вашего процессора. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.

+

Каталог готовых систем

Официальные образы, подходящие этому серверу. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.

{% if dl.catalog %}
@@ -31,6 +31,6 @@ {% endfor %}
{% else %} -

Для процессора этого сервера готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.

+

Подходящих этому серверу готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.

{% endif %}
diff --git a/web/templates/_downloads_url.html b/web/templates/_downloads_url.html index 47fde93..aed6e12 100644 --- a/web/templates/_downloads_url.html +++ b/web/templates/_downloads_url.html @@ -1,7 +1,7 @@ {#- Карточка «Скачать по ссылке». Ожидает переменную kind ('iso' | 'disk'). -#} {% from "_ui.html" import icon %}
-

Скачать по ссылке

Сервер скачает файл сам — не нужно гонять его через компьютер

+

Скачать по ссылке

Сервер скачает файл сам — не нужно сначала загружать его на компьютер

diff --git a/web/templates/disk_images.html b/web/templates/disk_images.html index b068b07..ae436cd 100644 --- a/web/templates/disk_images.html +++ b/web/templates/disk_images.html @@ -7,6 +7,7 @@ {% block content %} {% set kind = 'disk' %} +{% set dl = downloads_panel(kind) %} {% include "_downloads_active.html" %}
diff --git a/web/templates/iso.html b/web/templates/iso.html index 1a44995..b33b306 100644 --- a/web/templates/iso.html +++ b/web/templates/iso.html @@ -7,6 +7,7 @@ {% block content %} {% set kind = 'iso' %} +{% set dl = downloads_panel(kind) %} {% include "_downloads_active.html" %}