Files
OPNexus/backend/app/services/updater_client.py
T
Julian Boldt c662a58426
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Release v1.15.0
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener,
optionaler Container erledigt die Arbeit. Ende-zu-Ende mit echten Images getestet, siehe Handbuch
„Update aus dem Webinterface (optional)“:

- **Updates aus dem Webinterface (optional):** Einstellungen › „OPNexus aktualisieren“ zeigt die
  neueste Version samt Release-Notes und aktualisiert auf Klick — mit Fortschrittsanzeige,
  automatischem Rollback bei einem fehlgeschlagenen Update und „Auf Vorversion zurücksetzen“
  (mit Hinweis, ob dabei Daten verloren gehen). Dahinter arbeitet ein eigener, kleiner
  **Updater-Container** (`updater/`, Compose-Profil `updater`, mit `COMPOSE_PROFILES=updater`
  in `.env` aktivieren), der dieselbe Logik wie `opnexus update` ausführt. Er ist standardmäßig
  **aus** und der einzige Container mit Docker-Socket; das Backend bekommt ihn nie. Der Updater
  hat keinen veröffentlichten Port, ist nur mit einem selbst erzeugten Token ansprechbar und kann
  ausschließlich „aktualisieren auf die vom Release-Feed gemeldete neueste stabile Version“,
  „zurückrollen“ und „Status“. Hauptversions-Sprünge bleiben der Kommandozeile vorbehalten.
  Anfragen stehen im Audit-Log (neuer Typ „OPNexus-System“). Gilt nur für Release-Images.
- `opnexus` (jetzt in `updater/opnexus`, `scripts/opnexus` ist ein Symlink): `status --porcelain`,
  Exit-Code 3 für „fehlgeschlagen und sauber zurückgerollt“. Release-Images und -Bundle enthalten
  zusätzlich `opnexus-updater`.
- Der Banner „Neue Version verfügbar“ verlinkt auf den neuen Abschnitt in den Einstellungen.
2026-10-10 00:24:01 +02:00

71 lines
2.2 KiB
Python

"""Client für den optionalen OPNexus-Updater-Container (updater/server.py).
Das Backend kann selbst nichts am Host ändern und soll das auch nicht können:
Es fragt den Updater-Dienst nach dem Zustand und reicht eine bereits
geprüfte Update-/Rollback-Anfrage weiter. Ist der Dienst nicht aktiviert oder
nicht erreichbar, ist das ein normaler Zustand ("nicht verfügbar"), kein Fehler.
"""
import logging
from pathlib import Path
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
_STATUS_TIMEOUT = 50.0 # der Updater fragt docker ab (bis zu 45 s)
_ACTION_TIMEOUT = 15.0
class UpdaterUnavailable(Exception):
"""Updater nicht aktiviert (kein Token) oder nicht erreichbar."""
class UpdaterRejected(Exception):
def __init__(self, code: str):
super().__init__(code)
self.code = code
def _token() -> str:
try:
token = Path(settings.updater_token_file).read_text(encoding="utf-8").strip()
except OSError:
token = ""
if not token:
raise UpdaterUnavailable("not_enabled")
return token
async def _call(method: str, path: str, payload: dict | None, timeout: float) -> dict:
headers = {"Authorization": f"Bearer {_token()}"}
try:
async with httpx.AsyncClient(timeout=timeout) as http:
response = await http.request(method, settings.updater_url.rstrip("/") + path, headers=headers, json=payload)
except httpx.HTTPError as exc:
logger.info("Updater nicht erreichbar: %s", exc.__class__.__name__)
raise UpdaterUnavailable("unreachable") from exc
try:
body = response.json()
except ValueError:
body = {}
if response.status_code == 401:
# Token passt nicht (Volume neu angelegt?) — wie "nicht verfügbar" behandeln.
raise UpdaterUnavailable("unauthorized")
if response.status_code >= 400:
raise UpdaterRejected(str(body.get("error") or "rejected"))
return body
async def status() -> dict:
return await _call("GET", "/status", None, _STATUS_TIMEOUT)
async def start_update(version: str) -> dict:
return await _call("POST", "/update", {"version": version}, _ACTION_TIMEOUT)
async def start_rollback() -> dict:
return await _call("POST", "/rollback", {}, _ACTION_TIMEOUT)