"""Persistent OTA update state in data/update_state.json.""" from __future__ import annotations import json import os from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, Optional VALID_STATUSES = frozenset( {"idle", "in_progress", "success", "failed", "rolled_back", "pending_restart"} ) def _utc_now_iso() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") def update_state_path(base_dir: str) -> str: return os.path.join(base_dir, "data", "update_state.json") def read_update_state(base_dir: str) -> Dict[str, Any]: path = update_state_path(base_dir) if not os.path.isfile(path): return {"status": "idle"} try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): return data except (OSError, json.JSONDecodeError): pass return {"status": "idle"} def write_update_state(base_dir: str, payload: Dict[str, Any]) -> Dict[str, Any]: os.makedirs(os.path.join(base_dir, "data"), exist_ok=True) path = update_state_path(base_dir) current = read_update_state(base_dir) if current.get("status") == "idle" and "status" not in payload: payload = {**payload, "status": "in_progress"} merged = {**current, **payload} status = str(merged.get("status", "idle")) if status not in VALID_STATUSES: merged["status"] = "failed" with open(path, "w", encoding="utf-8") as f: json.dump(merged, f, ensure_ascii=False, indent=2) f.write("\n") return merged def mark_in_progress( base_dir: str, *, target_version: str, previous_version: str, backup_path: str, ) -> Dict[str, Any]: return write_update_state( base_dir, { "status": "in_progress", "target_version": target_version, "previous_version": previous_version, "backup_path": backup_path, "started_at": _utc_now_iso(), "stage": "prepare", "message": "Подготовка к обновлению…", "detail": None, "error": None, }, ) def mark_success(base_dir: str, *, target_version: str) -> Dict[str, Any]: return write_update_state( base_dir, { "status": "success", "target_version": target_version, "finished_at": _utc_now_iso(), "message": f"Обновление до версии {target_version} завершено", "error": None, }, ) def mark_failed( base_dir: str, *, message: str, stage: Optional[str] = None, detail: Optional[str] = None, ) -> Dict[str, Any]: payload: Dict[str, Any] = { "status": "failed", "message": message, "finished_at": _utc_now_iso(), "error": message, } if stage: payload["stage"] = stage if detail: payload["detail"] = detail return write_update_state(base_dir, payload) def mark_rolled_back( base_dir: str, *, previous_version: str, message: str, ) -> Dict[str, Any]: return write_update_state( base_dir, { "status": "rolled_back", "previous_version": previous_version, "message": message, "finished_at": _utc_now_iso(), "error": message, }, ) def mark_pending_restart(base_dir: str) -> Dict[str, Any]: return write_update_state( base_dir, { "status": "pending_restart", "stage": "restart", "message": "Перезапуск системы…", }, ) def clear_update_state(base_dir: str) -> None: path = update_state_path(base_dir) if os.path.isfile(path): try: os.remove(path) except OSError: pass