"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД. Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5; hub-hygiene-d решения 1–7.""" import hashlib import json import logging import os import re import threading from datetime import datetime, timezone from pathlib import Path from .paths import secure_dir log = logging.getLogger(__name__) MAX_SIZE = 1024 * 1024 VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) SHA256_RE = re.compile(r"[0-9a-f]{64}") _warned: str | None = None # текст последнего предупреждения о манифесте: одно на каждую новую ошибку (решение 5) _warned_lock = threading.Lock() class PublishError(Exception): pass def parse_version(text: str) -> int: found = VERSION_RE.findall(text) if len(found) != 1: raise PublishError(f"в файле должна быть ровно одна строка AGENT_VERSION = <целое>, найдено {len(found)}") return int(found[0]) def _write_atomic(path: Path, data: bytes) -> None: """Файл целиком или никак: .<имя>.tmp в том же каталоге, write + flush + fsync, переименование (решение 1). При OSError временный файл удаляется (ошибка удаления не важна), исключение пробрасывается.""" tmp = path.with_name("." + path.name + ".tmp") try: with open(tmp, "wb") as f: f.write(data) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) except OSError: try: tmp.unlink(missing_ok=True) except OSError: pass raise def publish(release_dir, source) -> dict: """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной). Прежний agent.py на время записи — agent.py.prev; сбой записи (OSError) возвращает его на место (решение 2): опубликованной остаётся прежняя версия, наружу — только PublishError.""" src = Path(source) try: data = src.read_bytes() except OSError as e: raise PublishError(f"файл не прочитан {src}: {e}") from e if len(data) > MAX_SIZE: raise PublishError(f"файл больше 1 МБ ({len(data)} байт)") try: text = data.decode("utf-8") except UnicodeDecodeError as e: raise PublishError("файл не в UTF-8") from e version = parse_version(text) rd = Path(release_dir) prev = current(rd) if prev and version <= prev["version"]: raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше") manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data), "published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} agent, prev_file = rd / "agent.py", rd / "agent.py.prev" existed, moved = agent.exists(), False try: secure_dir(rd) if agent.exists(): os.replace(agent, prev_file) moved = True _write_atomic(agent, data) _write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) except OSError as e: _undo(agent, prev_file, moved, existed) raise PublishError(f"не удалось записать публикацию: {e}") from e try: prev_file.unlink(missing_ok=True) # и прежний, и оставленный упавшей публикацией (риск в design) except OSError as e: log.warning("release: %s не удалён: %s", prev_file, e) return manifest def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None: """Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было — убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем. Сбой возврата — в лог: команда всё равно завершается сообщением.""" try: if moved: os.replace(prev_file, agent) elif not existed: agent.unlink(missing_ok=True) except OSError as e: log.error("release: прежняя публикация не восстановлена (%s): %s", prev_file if moved else agent, e) def _manifest_problem(m) -> str | None: """Почему манифест негоден, или None (решение 4; та же проверка, что у агента v9).""" if not isinstance(m, dict): return "не объект" version = m.get("version") if not isinstance(version, int) or isinstance(version, bool) or version < 1: return f"version — не целое ≥ 1: {version!r}" sha = m.get("sha256") if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha): return "sha256 — не 64 символа [0-9a-f]" return None def _note_manifest(problem: str | None, path: Path) -> None: """Предупреждение о негодном манифесте — один раз на каждый новый текст; годный или отсутствующий манифест сбрасывает память (решение 5). Замок: claim и GET /agent читают манифест из разных потоков.""" global _warned text = None if problem is None else f"release: манифест {path}: {problem} — публикации нет" with _warned_lock: if text is not None and text != _warned: log.warning("%s", text) _warned = text def current(release_dir) -> dict | None: """Опубликованная версия по манифесту; нет, битый или негодный манифест — None (агенту нечего обновлять).""" path = Path(release_dir) / "manifest.json" try: m = json.loads(path.read_text(encoding="utf-8")) except (FileNotFoundError, NotADirectoryError): _note_manifest(None, path) return None except (OSError, ValueError) as e: # нет прав, битый JSON: публикации нет, мост работает _note_manifest(f"не читается ({e})", path) return None problem = _manifest_problem(m) _note_manifest(problem, path) if problem: return None return {"version": m["version"], "sha256": m["sha256"], "published_at": m.get("published_at")} def load(release_dir) -> dict | None: """Файл агента для GET /agent/release; sha256 пересчитывается — несовпадение с манифестом = публикации нет.""" m = current(release_dir) if m is None: return None try: data = (Path(release_dir) / "agent.py").read_bytes() except OSError as e: log.error("release: agent.py не прочитан: %s", e) return None if hashlib.sha256(data).hexdigest() != m["sha256"]: log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет") return None try: content = data.decode("utf-8") except UnicodeDecodeError: # решение 6: 404, а не 500 log.error("release: agent.py не в UTF-8 — публикации нет") return None return {"version": m["version"], "sha256": m["sha256"], "content": content} def hint(rel, agent, update_state, launcher_version, reported=True) -> str | None: """Подсказка для agent_status (design agent-self-update решение 5). reported=False — после запуска хаба claim ещё не было: launcher_version неизвестен, подсказки «без run.py» нет (hub-hygiene-d, решение 7).""" try: agent_version = int(agent.get("agent_version") or 0) except (TypeError, ValueError): agent_version = 0 if (reported and rel and agent.get("online") and agent_version and rel["version"] > agent_version and launcher_version is None): return "агент запущен без run.py — обновится только вручную" us = update_state or {} if us.get("state") == "rolled_back": return f"откат с v{us.get('to_version')} на v{us.get('from_version')}: {us.get('error') or '—'}" if us.get("state") == "rejected": return f"v{us.get('to_version')} отвергнута: {us.get('error') or '—'}" return None