"""Mises à jour logicielles. - Détection : dernier tag semver du dépôt Gitea via son API (jeton requis, repo privé). - Application : POST à Watchtower (compagnon docker-compose) qui tire la nouvelle image et recrée le conteneur — quelques secondes d'indisponibilité. """ import logging import re import time import httpx from app.config import get_settings from app.services.settings import get_update_config from app.version import get_version logger = logging.getLogger(__name__) _TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") _CACHE_TTL = 300.0 # secondes _cache: dict[str, object] = {"checked_at": 0.0, "latest": None} class UpdateError(Exception): """Erreur de mise à jour (config absente, Gitea injoignable, Watchtower KO).""" def parse_tag(tag: str) -> tuple[int, int, int] | None: """'v0.2.1' → (0, 2, 1) ; None si le tag n'est pas un semver strict.""" m = _TAG_RE.match(tag.strip()) return tuple(int(g) for g in m.groups()) if m else None # type: ignore[return-value] def is_newer(latest: str, current: str) -> bool: a, b = parse_tag(latest), parse_tag(current) if a is None or b is None: return False return a > b async def fetch_latest_version(*, force: bool = False) -> str | None: """Dernier tag semver du dépôt (cache 5 min). None si non configuré ou erreur.""" now = time.monotonic() latest_cache = _cache["latest"] if not force and latest_cache and now - float(_cache["checked_at"]) < _CACHE_TTL: return str(latest_cache) # type: ignore[arg-type] config = await get_update_config() if not config["repo"]: return None url = f"{config['gitea_url']}/api/v1/repos/{config['repo']}/tags?limit=20" # Jeton requis uniquement pour un dépôt privé (public en lecture : inutile) headers = {"Authorization": f"token {config['token']}"} if config["token"] else {} try: async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: resp = await client.get(url, headers=headers) resp.raise_for_status() tags = [t["name"] for t in resp.json() if parse_tag(t.get("name", ""))] except (httpx.HTTPError, ValueError, KeyError) as exc: logger.warning("Vérification de mise à jour impossible : %s", exc) return None latest = max(tags, key=parse_tag) if tags else None # type: ignore[arg-type] _cache.update(checked_at=now, latest=latest) if latest and is_newer(latest, get_version()): logger.info("Nouvelle version disponible : %s (courante %s)", latest, get_version()) return latest def invalidate_cache() -> None: _cache.update(checked_at=0.0, latest=None) async def status() -> dict[str, object]: """État complet : version courante, dernière dispo, config.""" config = await get_update_config() current = get_version() latest = await fetch_latest_version() return { "current": current, "latest": latest, "update_available": bool(latest and is_newer(latest, current)), "configured": bool(config["repo"]), "docker": bool(get_settings().watchtower_url), "config": config, } async def trigger_update() -> dict[str, str]: """Demande à Watchtower de recréer le conteneur avec la dernière image. Le conteneur courant (donc cette requête) disparaît quelques secondes après : la réponse est renvoyée immédiatement, le frontend gère la reconnexion. """ settings = get_settings() if not settings.watchtower_url: raise UpdateError( "Watchtower non configuré — mise à jour disponible uniquement en déploiement Docker" ) headers = {} if settings.watchtower_token: headers["Authorization"] = f"Bearer {settings.watchtower_token}" try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.post(f"{settings.watchtower_url}/v1/update", headers=headers) resp.raise_for_status() except httpx.HTTPError as exc: raise UpdateError(f"Watchtower injoignable : {exc}") from exc logger.info("Mise à jour déclenchée via Watchtower (version courante %s)", get_version()) return {"status": "started"}