Исправлено разрушение папки app при обновлении на Windows
Обновление перемещало папку app целиком, а код выполняется именно из неё. Windows не даёт двигать такую папку: перемещение не прошло, откат тоже, и app исчезла - сервис переставал запускаться. Теперь папка остаётся на месте: новая версия проверяется во временном каталоге ДО установки, затем файлы перезаписываются по одному с копией для отката. Устаревшие модули удаляются, __pycache__ не трогается. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b8cbaa6b82
commit
59857fff0f
+69
-32
@@ -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)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "0.1.3"
|
||||
__version__ = "0.1.4"
|
||||
|
||||
Reference in New Issue
Block a user