Deploy: Health-Check wartet bei neuem Ingress geduldig auf Zertifikat/DNS (bis ~15 min), endet dann mit Warnung statt failed; Zustand wird vor dem Health-Check festgehalten; 'Health erneut prüfen'

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
RubenRWU
2026-10-15 16:24:36 +02:00
parent f33312369f
commit 31a99e31c0
8 changed files with 123 additions and 16 deletions

View File

@@ -135,10 +135,38 @@ def _client(live: bool, log, http=None) -> Mittwald:
return Mittwald(os.getenv("MITTWALD_API_TOKEN", "").strip(), os.getenv("MITTWALD_API_BASE", "https://api.mittwald.de/v2"), live, log, http)
def recheck_health(tenant_id: str, *, health=None, tries: int = 8, delay: float = 5.0) -> dict:
"""Health-Check nachholen (z. B. wenn das Zertifikat inzwischen da ist). Bei Erfolg wird ein hängender Deploy-Eintrag auf „success“ gesetzt."""
lines: list[str] = []
with session() as db:
t = db.get(Tenant, tenant_id)
host, tag, ver, source = host_for(db, t), effective_tag(db, t), t.current_version, t.source
deployed = (t.deployed_version, t.deployed_image)
try:
args = (host, deployed[1] or tag, ver if source == "admin" else None, lines.append)
health(*args) if health else verify_health(*args, tries=tries, delay=delay)
except DeployError as exc:
return {"ok": False, "log": lines + [f"✘ {exc}"]}
with session() as db:
d = db.scalar(select(Deployment).where(Deployment.tenant_id == tenant_id, Deployment.kind == "deploy").order_by(Deployment.id.desc()))
if d is not None and d.status in ("warning", "failed") and not d.simulated:
d.status = "success"
d.log += f"{datetime.now(timezone.utc).strftime('%H:%M:%S')} Health nachträglich ✔ – Deploy als erfolgreich markiert\n"
return {"ok": True, "log": lines}
def _hex_tag(tag: str) -> bool:
return bool(tag) and all(c in "0123456789abcdef" for c in tag.lower()) and len(tag) >= 7
class HealthTimeout(DeployError):
"""Health-Check nicht grün geworden. answered=False: es kam nie eine gültige Antwort (Zertifikat/DNS/Container noch nicht bereit)."""
def __init__(self, msg: str, answered: bool):
super().__init__(msg)
self.answered = answered
def verify_health(host: str, expect_sha: str | None, expect_bundle: int | None, log, *, get=None, tries: int = 18, delay: float = 5.0,
sleep=time.sleep) -> None:
"""Prüft nach dem Deploy, ob wirklich läuft, was wir ausgerollt haben (Vertrag: /health.core / .bundle).
@@ -149,10 +177,11 @@ def verify_health(host: str, expect_sha: str | None, expect_bundle: int | None,
def get(url):
r = httpx.get(url, timeout=8, follow_redirects=True)
return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {})
last = "keine Antwort"
last, answered = "keine Antwort", False
for i in range(tries):
try:
status, body = get(f"https://{host}/health")
answered = answered or (status == 200 and isinstance(body, dict) and "status" in body) # echte Antwort der App (nicht Proxy/Zertifikatsfehler)
problems = []
if status != 200 or body.get("status") != "ok":
problems.append(f"HTTP {status}")
@@ -176,7 +205,7 @@ def verify_health(host: str, expect_sha: str | None, expect_bundle: int | None,
last = f"{type(exc).__name__}: {exc}"
if i < tries - 1:
sleep(delay)
raise DeployError(f"Health-Check fehlgeschlagen nach {tries} Versuchen: {last}")
raise HealthTimeout(f"Health-Check fehlgeschlagen nach {tries} Versuchen: {last}", answered)
def execute(dep_id: int, tenant_id: str, kind: str, http=None, oidc_client=None, core_tag: str | None = None, health=None) -> bool:
@@ -230,16 +259,32 @@ def execute(dep_id: int, tenant_id: str, kind: str, http=None, oidc_client=None,
else:
mw.deploy_service(stack_id=sid, service=f"app-{tenant_id}", volume=f"vol-{tenant_id}", definition=definition,
container_port=PORT, project_id=project, hostname=host)
if live and not kind.startswith("offboard") and host:
(health or verify_health)(host, tag, ver if source == "admin" else None, log)
if not dry:
warning = False
if not dry: # Zustand sofort festhalten: geschrieben ist geschrieben, auch wenn der Health-Check noch wartet
with session() as db:
t = db.get(Tenant, tenant_id)
if kind == "deploy":
t.deployed_version, t.deployed_image, t.deployed_at = ver, tag, datetime.now(timezone.utc)
store.audit(db, "system", f"{kind}.live", tenant_id, f"v{ver} :{tag}")
log("✔ Fertig" if live else "Dry-Run beendet – es wurde nichts geschrieben.")
return True
if live and not kind.startswith("offboard") and host:
fresh = mw.ingress_created
# Neuer Ingress: Zertifikat/DNS bei Mittwald brauchen oft mehrere Minuten → geduldig warten (bis ~15 min)
kw = {"tries": 90, "delay": 10.0} if fresh else {}
if fresh:
log("Neuer Ingress angelegt – Zertifikat/DNS bei Mittwald können einige Minuten dauern, Health-Check wartet geduldig …")
try:
args = (host, tag, ver if source == "admin" else None, log)
health(*args) if health else verify_health(*args, **kw)
except HealthTimeout as exc:
if fresh and not exc.answered: # nur „noch nicht erreichbar“ bei neuem Ingress: Deploy ist durch, Prüfung später wiederholen
warning = True
log(f"⚠ {exc}")
log("⚠ Container und Ingress sind angelegt, aber die Adresse antwortet noch nicht (Zertifikat/DNS?). "
"Später „Health erneut prüfen“ (Deploy-Tab) oder „Läuft?“ (Übersicht).")
else:
raise
log("✔ Fertig" if live and not warning else ("Fertig mit Warnung" if warning else "Dry-Run beendet – es wurde nichts geschrieben."))
return "warning" if warning else True
except DeployError as exc:
log(f"✘ {exc}")
return False
@@ -260,7 +305,7 @@ def start(tenant_id: str, kind: str, actor: str, batch_id: str = "", http=None,
def work():
ok = execute(dep_id, tenant_id, kind, http, oidc_client, core_tag, health)
_finish(dep_id, "success" if ok else "failed")
_finish(dep_id, "warning" if ok == "warning" else ("success" if ok else "failed"))
th = threading.Thread(target=work, daemon=True)
th.start()
@@ -303,8 +348,8 @@ def run_sequence(tenant_ids: list[str], kind: str, actor: str, *, batch: str, co
with session() as db:
db.get(Deployment, did).status = "running"
ok = execute(did, tid, kind, http, oidc_client, core_tag, health)
_finish(did, "success" if ok else "failed")
results[tid] = "success" if ok else "failed"
_finish(did, "warning" if ok == "warning" else ("success" if ok else "failed"))
results[tid] = "warning" if ok == "warning" else ("success" if ok else "failed")
failed = not ok
if tid in canary and ok and tid == canary[-1]:
_append(did, "Canary erfolgreich – restliche Tenants folgen")

View File

@@ -30,6 +30,7 @@ class Mittwald:
self.token, self.base, self.live, self.log = token, base.rstrip("/"), live, log
self._http = http or self._real_http
self._project_uuid: str | None = None
self.ingress_created = False # wurde in diesem Lauf ein NEUER Ingress angelegt? (dann dauert Zertifikat/DNS)
@staticmethod
def _real_http(method, url, headers, body):
@@ -151,6 +152,8 @@ class Mittwald:
body = {"hostname": hostname, "projectId": self.project_uuid(project_id),
"paths": [{"path": "/", "target": {"container": {"id": container_id, "portProtocol": f"{port}/tcp"}}}]}
res = self.try_api("POST", "/ingresses", body, write=True, fatal=False)
if self.live and res is not None:
self.ingress_created = True
if self.live:
self.log(f"Ingress {hostname} angelegt (Auto-TLS)" if res is not None else
f"⚠ Ingress {hostname} NICHT angelegt – Token braucht Domain/Ingress-Recht (Service läuft trotzdem).")