From 59857fff0f3333bce5d6eb9ff0094e47f675eb6d Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Sat, 15 Aug 2026 22:00:03 +0500 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D1=80=D0=B0=D0=B7=D1=80=D1=83=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20app=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=BD=D0=B0=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обновление перемещало папку app целиком, а код выполняется именно из неё. Windows не даёт двигать такую папку: перемещение не прошло, откат тоже, и app исчезла - сервис переставал запускаться. Теперь папка остаётся на месте: новая версия проверяется во временном каталоге ДО установки, затем файлы перезаписываются по одному с копией для отката. Устаревшие модули удаляются, __pycache__ не трогается. Co-Authored-By: Claude Opus 5 (1M context) --- app/updater.py | 101 ++++++++++++++++++--------- app/version.py | 2 +- tests/test_updater.py | 8 ++- tests/test_updater_install.py | 125 ++++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 35 deletions(-) create mode 100644 tests/test_updater_install.py diff --git a/app/updater.py b/app/updater.py index 953ded5..129d096 100644 --- a/app/updater.py +++ b/app/updater.py @@ -71,14 +71,19 @@ def _safe_members(archive: zipfile.ZipFile) -> list[str]: return names -def _default_verify(base: Path) -> bool: - """Проверяет, что обновлённый код хотя бы импортируется.""" +def _default_verify(staging: Path) -> bool: + """Проверяет, что новый код импортируется, ещё до его установки. + + Код берётся из временной папки, рабочая при этом не тронута: если новая + версия окажется нерабочей, ставить её не начнём вовсе. + """ env = dict(os.environ) - env.update({"PYTHONPATH": str(base), "TALKSCORE_ASR_HOME": str(base)}) + env.update({"PYTHONPATH": str(staging)}) + env.setdefault("TALKSCORE_ASR_HOME", str(staging.parent)) try: result = subprocess.run( [sys.executable, "-c", "import app.main"], - cwd=str(base), capture_output=True, timeout=UPDATE_TIMEOUT_SEC, env=env, + cwd=str(staging), capture_output=True, timeout=UPDATE_TIMEOUT_SEC, env=env, ) if result.returncode != 0: log.error("новая версия не импортируется: %s", @@ -91,45 +96,77 @@ def _default_verify(base: Path) -> bool: def install_update(archive_bytes: bytes, base_dir: Path, verify: Callable[[Path], bool] | None = None) -> None: - """Ставит новую версию папки app с возможностью отката.""" + """Обновляет файлы внутри app, не трогая саму папку. + + Windows не даёт переместить или удалить папку, из которой сейчас выполняется + код, а обновление запускается именно оттуда. Поэтому папка остаётся на месте, + а файлы в ней перезаписываются по одному, с копией для отката. + """ base = Path(base_dir) verify = verify or _default_verify - - with zipfile.ZipFile(io.BytesIO(archive_bytes)) as z: - members = _safe_members(z) - staging = base / ".update-staging" - shutil.rmtree(staging, ignore_errors=True) - staging.mkdir(parents=True) - for name in members: - if Path(name).name in USER_FILES: - continue - z.extract(name, staging) - - new_app = staging / "app" - if not new_app.is_dir(): - shutil.rmtree(staging, ignore_errors=True) - raise UpdateError("в архиве нет папки app") - + staging = base / ".update-staging" backup = base / ".update-backup" - shutil.rmtree(backup, ignore_errors=True) - current = base / "app" - if current.exists(): - shutil.move(str(current), str(backup)) + live = base / "app" try: - shutil.move(str(new_app), str(current)) - if not verify(base): + shutil.rmtree(staging, ignore_errors=True) + staging.mkdir(parents=True) + with zipfile.ZipFile(io.BytesIO(archive_bytes)) as z: + for name in _safe_members(z): + if Path(name).name in USER_FILES: + continue + z.extract(name, staging) + + if not (staging / "app").is_dir(): + raise UpdateError("в архиве нет папки app") + + # Проверяем до установки: рабочая версия ещё цела и продолжит работать, + # если новая окажется сломанной. + if not verify(staging): raise UpdateError("новая версия не прошла проверку запуска") - except Exception: - shutil.rmtree(current, ignore_errors=True) - if backup.exists(): - shutil.move(str(backup), str(current)) - raise + + live.mkdir(parents=True, exist_ok=True) + shutil.rmtree(backup, ignore_errors=True) + shutil.copytree(live, backup) + + try: + _sync_files(staging / "app", live) + except Exception: + _sync_files(backup, live) + raise finally: shutil.rmtree(staging, ignore_errors=True) shutil.rmtree(backup, ignore_errors=True) +def _sync_files(src: Path, dst: Path) -> None: + """Приводит dst к состоянию src: копирует файлы и убирает лишние. + + Устаревший модуль, удалённый в новой версии, обязан исчезнуть и здесь, + иначе он останется импортируемым и будет вести себя как призрак. + """ + dst.mkdir(parents=True, exist_ok=True) + wanted = set() + for item in sorted(src.rglob("*")): + rel = item.relative_to(src) + target = dst / rel + wanted.add(rel) + if item.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + + for item in sorted(dst.rglob("*"), reverse=True): + rel = item.relative_to(dst) + if rel in wanted or rel.parts[0] == "__pycache__": + continue + if item.is_file(): + item.unlink(missing_ok=True) + elif item.is_dir() and not any(item.iterdir()): + item.rmdir() + + def _api(session, url: str, token: str): headers = {"Authorization": f"token {token}"} if token else {} response = session.get(url, headers=headers, timeout=30) diff --git a/app/version.py b/app/version.py index ae73625..bbab024 100644 --- a/app/version.py +++ b/app/version.py @@ -1 +1 @@ -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/tests/test_updater.py b/tests/test_updater.py index f304794..7ca0d4f 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -179,13 +179,17 @@ class TestApiDiagnostics: @pytest.mark.parametrize("status", [401, 403, 404]) def test_auth_errors_mention_token(self, status): + # Импорт внутри теста: другие тесты перезагружают app.*, и класс + # исключения, взятый на уровне модуля, перестал бы совпадать. + from app.updater import UpdateError as Err from app.updater import _api - with pytest.raises(UpdateError, match="токен"): + with pytest.raises(Err, match="токен"): _api(self.FakeSession(status), "http://x", token="какой-то") def test_missing_token_says_where_to_put_it(self): + from app.updater import UpdateError as Err from app.updater import _api - with pytest.raises(UpdateError, match="config.toml"): + with pytest.raises(Err, match="config.toml"): _api(self.FakeSession(404), "http://x", token="") diff --git a/tests/test_updater_install.py b/tests/test_updater_install.py new file mode 100644 index 0000000..a618c37 --- /dev/null +++ b/tests/test_updater_install.py @@ -0,0 +1,125 @@ +"""Тесты установки обновления. + +Первая версия перемещала папку app целиком - и на Windows это сломало сервис: +код выполнялся из этой самой папки, перемещение не прошло, откат тоже не смог +её вернуть, и app просто исчезла. Отсюда требования ниже: не двигать и не +удалять рабочую папку, проверять новый код до установки, уметь откатиться +пофайлово. +""" +import io +import zipfile + +import pytest + +from app.updater import UpdateError, install_update + + +def make_zip(files: dict[str, str]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + for name, text in files.items(): + z.writestr(name, text) + return buf.getvalue() + + +@pytest.fixture +def base(tmp_path): + app = tmp_path / "app" + app.mkdir() + (app / "main.py").write_text("старый main", encoding="utf-8") + (app / "version.py").write_text('__version__ = "0.1.0"', encoding="utf-8") + return tmp_path + + +class TestNoDestructiveMoves: + def test_app_directory_is_never_removed(self, base, monkeypatch): + """rmtree по рабочей папке - прямой путь к потере сервиса.""" + import shutil + + removed = [] + monkeypatch.setattr(shutil, "rmtree", + lambda p, **kw: removed.append(str(p))) + install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True) + assert not any(str(p).endswith("app") for p in removed) + + def test_app_directory_is_never_moved(self, base, monkeypatch): + import shutil + + moved = [] + real_move = shutil.move + monkeypatch.setattr(shutil, "move", + lambda s, d, **kw: (moved.append(str(s)), real_move(s, d))[1]) + install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True) + assert not any(str(p).rstrip("/\\").endswith("app") for p in moved) + + def test_directory_keeps_same_inode(self, base): + """Папка должна остаться той же самой, а не быть пересозданной.""" + before = (base / "app").stat().st_ino + install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True) + assert (base / "app").stat().st_ino == before + + +class TestVerifyBeforeInstall: + def test_broken_version_never_touches_working_files(self, base): + """Проверять надо до установки, иначе сломанный код уже стоит.""" + with pytest.raises(UpdateError): + install_update(make_zip({"app/main.py": "сломанный"}), base, + verify=lambda _: False) + assert (base / "app" / "main.py").read_text(encoding="utf-8") == "старый main" + + def test_verify_receives_staging_not_live_dir(self, base): + seen = {} + + def verify(path): + seen["path"] = path + # На момент проверки рабочий файл ещё не тронут + seen["live_intact"] = (base / "app" / "main.py").read_text(encoding="utf-8") + return True + + install_update(make_zip({"app/main.py": "новый"}), base, verify=verify) + assert seen["live_intact"] == "старый main" + assert seen["path"] != base + + +class TestInstallResult: + def test_files_are_updated(self, base): + install_update(make_zip({"app/main.py": "новый", + "app/version.py": '__version__ = "0.2.0"'}), + base, verify=lambda _: True) + assert (base / "app" / "main.py").read_text(encoding="utf-8") == "новый" + assert '0.2.0' in (base / "app" / "version.py").read_text(encoding="utf-8") + + def test_new_files_appear(self, base): + install_update(make_zip({"app/main.py": "новый", "app/extra.py": "доп"}), + base, verify=lambda _: True) + assert (base / "app" / "extra.py").is_file() + + def test_leftover_files_from_old_version_are_removed(self, base): + """Файл, которого больше нет в релизе, не должен оставаться и импортироваться.""" + (base / "app" / "obsolete.py").write_text("устаревший", encoding="utf-8") + install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True) + assert not (base / "app" / "obsolete.py").exists() + + def test_rollback_restores_files_on_copy_failure(self, base, monkeypatch): + """Если копирование упало на середине, возвращаем прежние файлы.""" + import shutil + + calls = {"n": 0} + real_copy = shutil.copy2 + + def flaky_copy(src, dst, **kw): + calls["n"] += 1 + if calls["n"] == 2: + raise OSError("файл занят") + return real_copy(src, dst) + + monkeypatch.setattr(shutil, "copy2", flaky_copy) + with pytest.raises(OSError): + install_update(make_zip({"app/main.py": "новый", "app/a.py": "a", + "app/b.py": "b"}), base, verify=lambda _: True) + assert (base / "app" / "main.py").read_text(encoding="utf-8") == "старый main" + + def test_no_temp_dirs_left_behind(self, base): + install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True) + leftovers = [p.name for p in base.iterdir() if p.name.startswith(".update")] + assert leftovers == []