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
+65 -1
View File
@@ -1,7 +1,8 @@
"""Paramètres persistés en DB (activation des sources, réglages UI…)."""
"""Paramètres persistés en DB (activation des sources, santé, réglages UI…)."""
import json
import logging
import secrets
from datetime import UTC, datetime
from app.db import db
@@ -35,6 +36,48 @@ async def set_source_enabled(name: str, enabled: bool) -> None:
await set_setting(f"source:{name}:enabled", enabled)
logger.info("Source %s %s", name, "activée" if enabled else "désactivée")
async def get_source_health(name: str) -> dict | None:
"""Dernier état de santé connu d'une source (None si jamais testée)."""
value = await get_setting(f"source:{name}:health")
return value if isinstance(value, dict) else None
async def set_source_health(name: str, healthy: bool, detail: str) -> dict:
state = {
"healthy": healthy,
"detail": detail,
"checked_at": datetime.now(UTC).isoformat(),
}
await set_setting(f"source:{name}:health", state)
return state
async def get_source_base_url(name: str) -> str | None:
"""URL personnalisée d'une source (None = valeur par défaut du code)."""
value = await get_setting(f"source:{name}:base_url")
return value if isinstance(value, str) and value else None
async def set_source_base_url(name: str, url: str | None) -> None:
"""Persiste l'URL personnalisée (None/'' → retour à la valeur par défaut)."""
key = f"source:{name}:base_url"
if url:
await set_setting(key, url)
else:
await db.execute("DELETE FROM settings WHERE key = ?", (key,))
async def apply_source_base_urls() -> None:
"""Applique les URL personnalisées aux instances de sources (au démarrage)."""
from app.scrapers.base import all_sources
for source in all_sources():
override = await get_source_base_url(source.name)
if override and override != type(source).base_url:
source.base_url = override
logger.info("URL personnalisée pour %s : %s", source.name, override)
# ---------------------------------------------------------------- intégrations *arr
TORZNAB_APIKEY_KEY = "torznab:apikey"
@@ -70,3 +113,24 @@ async def set_sonarr_config(url: str, apikey: str) -> None:
await set_setting(SONARR_URL_KEY, url.rstrip("/"))
await set_setting(SONARR_APIKEY_KEY, apikey.strip())
logger.info("Configuration Sonarr enregistrée (%s)", url)
# ---------------------------------------------------------------- mise à jour logicielle
UPDATE_GITEA_URL_KEY = "update:gitea_url"
UPDATE_REPO_KEY = "update:repo"
UPDATE_TOKEN_KEY = "update:token"
async def get_update_config() -> dict[str, str]:
return {
"gitea_url": await get_setting(UPDATE_GITEA_URL_KEY, "https://git.lanro.eu"),
"repo": await get_setting(UPDATE_REPO_KEY, "Roman/ohm_streaming"),
"token": await get_setting(UPDATE_TOKEN_KEY, ""),
}
async def set_update_config(gitea_url: str, repo: str, token: str) -> None:
await set_setting(UPDATE_GITEA_URL_KEY, gitea_url.rstrip("/"))
await set_setting(UPDATE_REPO_KEY, repo.strip().strip("/"))
await set_setting(UPDATE_TOKEN_KEY, token.strip())
logger.info("Configuration de mise à jour enregistrée (%s)", repo)
+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"}