Mise à jour automatique (Gitea + Watchtower) et conteneurisation Docker

- Détection de version via l'API Gitea, application via Watchtower
- Router système, UI admin (intégrations), bandeau de mise à jour
- Dockerfile multi-étapes, docker-compose, scripts/release.sh
- Version centralisée dans app/version.py (pyproject ou OHM_VERSION au build)
This commit is contained in:
Roman
2026-09-22 11:58:03 +00:00
parent 41566ab5fb
commit 9148b5fb6a
21 changed files with 1092 additions and 49 deletions
+113
View File
@@ -0,0 +1,113 @@
"""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"}