"""„Jetzt updaten“ im Homelab (Phase 4, User-Entscheide 24.09.2026). Nur per Knopf, mit Erfolgsmeldung und Erreichbarkeits-Check; ist er rot, geht es automatisch zurück und es wird gemeldet. Host-Updates gibt es mit Warnung, der Neustart ist ein eigener Knopf. Ablauf für einen Gast: 1. Rückweg anlegen: ein Snapshot; wo keiner geht (PBS: Bind-Mount), eine Sicherung (vzdump auf einen lokalen Speicher des Hosts, nie auf den PBS); geht beides nicht, ohne Rückweg — die Rückfrage sagt es. Scheitert dieser Schritt, beginnt das Update gar nicht. 2. Update: die App per Community-Script (`update`, still) oder die Pakete des Gasts. Ein frisch geändertes Update-Skript wartet erst MC_HOMELAB_KARENZ_H Stunden (karenz.py). 3. 20 s warten, frischen Bericht holen 4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer? 5. Rot → zurück auf den Snapshot bzw. die Sicherung zurückspielen → dringende Meldung. Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg (nicht die, die jemand auf der Seite Snapshots per Knopf angelegt hat, snapshots.py) → Meldung „eingespielt“. Docker-Images (Arcane, VM 106) gehen über Arcanes eigenen Updater: bis zum Schalter „echt“ nur als Probelauf, danach genauso mit Snapshot vorher und Rückweg bei Rot — sofern die VM freigegeben ist (Etikett watcher, seit 25.09.), sonst ohne; Rückfrage und Update-Liste sagen es. Nach grüner Prüfung geht dieser Snapshot gleich wieder weg. Jeder Lauf steht in /homelab-laeufe.json und im strukturierten Update-Verlauf. Pro Ziel läuft höchstens ein Lauf; startet dieser Teil neu, gilt ein offener Lauf als unterbrochen. „Rückweg testen“ (Baustein „probe“, seit 25.09.2026): Snapshot bzw. Sicherung anlegen, die Prüfung absichtlich als rot werten, zurückgehen und nachprüfen, ob das Gerät danach läuft und antwortet. Ein Update gibt es dabei nicht; so lässt sich der Weg zurück jederzeit echt beweisen, ohne auf ein gescheitertes Update zu warten. „Alle aktualisieren“ (sammellauf.py, seit 24.09.2026) stößt dieselben Läufe nacheinander an. Solange es läuft, lehnt der einzelne Knopf ab (SAMMELLAUF_SPERRE), und die Erfolgsmeldung eines Schritts entfällt — die Sammelmeldung am Ende sagt es; Fehler werden weiter sofort gemeldet. """ import json import logging import re import threading import time import uuid from datetime import datetime from pathlib import Path from kern.einstellungen import einstellungen from kern.zeit import LOCAL_TZ from services import announce, update_verlauf from services.homelab import apps, arcane, inventar, kanal, karenz log = logging.getLogger(__name__) WARTEN_NACH_UPDATE_S = 20 ZEITLIMIT_UPDATE_S = 45 * 60 ZEITLIMIT_KURZ_S = 15 * 60 ZEITLIMIT_SICHERUNG_S = 40 * 60 # Sicherung und Zurückspielen (der Ausführer gibt nach 30 min auf) ARCANE_FRIST_S = 180 # so lange darf Arcane nach einem Docker-Update brauchen, bis es wieder antwortet BETREFF = "[Homelab-Update]" BETREFF_ALARM = "[Alarm] Homelab-Update" SAMMELLAUF_SPERRE = "Es läuft gerade ‚Alle aktualisieren‘." _lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre # Prüfen und Anlegen eines Laufs am Stück — auch „Alle aktualisieren“ legt sich unter ihr an. Sonst könnte ein # einzelner Knopf genau zwischen Prüfung und Anlegen des Sammellaufs durchrutschen. START_SPERRE = threading.Lock() def _pfad() -> Path: return einstellungen().daten_dir / "homelab-laeufe.json" def _laeufe() -> list[dict]: with _lock: try: daten = json.loads(_pfad().read_text(encoding="utf-8")) except (OSError, ValueError): return [] return daten if isinstance(daten, list) else [] def _merken(lauf: dict) -> None: with _lock: laeufe = [x for x in _laeufe() if x["id"] != lauf["id"]] + [lauf] _pfad().parent.mkdir(parents=True, exist_ok=True) tmp = _pfad().with_suffix(".tmp") tmp.write_text(json.dumps(laeufe[-100:], ensure_ascii=False), encoding="utf-8") tmp.replace(_pfad()) def laeufe(anzahl: int = 20) -> list[dict]: return list(reversed(_laeufe()))[:anzahl] def lauf_lesen(lauf_id: str) -> dict | None: return next((x for x in _laeufe() if x.get("id") == lauf_id), None) def laufende_ziele() -> set[str]: """Ziele mit einem laufenden Update. Liest nur die Datei — geht also auch im Steward (Wächter, Pflege).""" return {str(x.get("ziel")) for x in _laeufe() if x.get("status") == "laeuft"} def laeuft(ziel_id: str) -> bool: return ziel_id in laufende_ziele() def unterbrochene_abschliessen() -> None: """Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess — ebenso ein laufendes „Alle aktualisieren“ (danach, damit es den unterbrochenen Lauf schon als solchen sieht).""" for lauf in _laeufe(): if lauf.get("status") == "laeuft": lauf.update(status="unterbrochen", ende=time.time()) lauf["schritte"].append("Der Homelab-Teil wurde während des Laufs neu gestartet.") _merken(lauf) from services.homelab import sammellauf # sammellauf importiert dieses Modul sammellauf.unterbrochene_abschliessen() def _sammellauf_laeuft() -> bool: from services.homelab import sammellauf return sammellauf.laeuft() def _melden(text: str, dringend: bool = False) -> None: try: announce.notify_telegram(BETREFF_ALARM if dringend else BETREFF, text) except Exception: log.warning("homelab: Meldung ging nicht raus", exc_info=True) def _auftrag(lauf: dict, aktion: str, parameter: dict, zeitlimit_s: float) -> dict: """Auftrag an den Ausführer und auf das Ergebnis warten. Wirft RuntimeError bei Fehler/Zeitablauf.""" aid = kanal.anlegen(aktion, parameter) lauf["schritte"].append(f"{aktion} …") _merken(lauf) a = kanal.warten(aid, zeitlimit_s) if a is None: raise RuntimeError(f"{aktion}: keine Antwort des Ausführers in {int(zeitlimit_s // 60)} Minuten") if a["status"] != "fertig": raise RuntimeError(f"{aktion} gescheitert: {str(a.get('text') or '')[-300:]}") lauf["schritte"][-1] = f"{aktion}: ok" return a def _frischer_gast(lauf: dict, vmid: int) -> dict | None: try: a = _auftrag(lauf, "bericht", {}, 5 * 60) bericht = json.loads(a.get("text") or "{}") kanal.bericht_speichern(bericht, aus_auftrag=True) except (RuntimeError, ValueError): return None return next((g for g in bericht.get("gaeste") or [] if g.get("vmid") == vmid), None) def pruefen(vorher: dict, nachher: dict | None, baustein: str) -> list[str]: """Was nach dem Update nicht stimmt (leer = grün).""" if nachher is None: return ["Der Gast fehlt im neuen Bericht."] fehler = [] if nachher.get("status") != "running": fehler.append(f"Der Gast läuft nicht ({nachher.get('status')}).") app = apps.app_fuer((nachher.get("app") or {}).get("kennung"), nachher.get("name")) url = apps.adresse(app, nachher.get("ip")) if url and not inventar.erreichbar_frisch(url): fehler.append(f"Die Weboberfläche ({url}) antwortet nicht.") if baustein == "app": alt, neu = (vorher.get("app") or {}).get("version"), (nachher.get("app") or {}).get("version") if alt and neu and alt == neu: fehler.append(f"Die App-Version ist unverändert ({alt}).") return fehler def _rueckweg_anlegen(lauf: dict, gast: dict) -> tuple[str, str] | None: """Vor dem Update: ("snapshot", Name), wo keiner geht ("sicherung", Archiv), sonst None. Wirft RuntimeError, wenn der Schritt scheitert (etwa zu wenig Platz) — dann beginnt das Update gar nicht.""" vmid = gast["vmid"] if gast.get("snapshot_moeglich"): a = _auftrag(lauf, "snapshot", {"vmid": vmid}, ZEITLIMIT_KURZ_S) m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "") return ("snapshot", m.group(1)) if m else None if gast.get("sicherung_moeglich"): a = _auftrag(lauf, "sichern", {"vmid": vmid}, ZEITLIMIT_SICHERUNG_S) m = re.search(r"^sicherung=(\S+)$", a.get("text") or "", re.MULTILINE) if not m: raise RuntimeError("sichern: Der Ausführer nannte keine Sicherung") return ("sicherung", m.group(1)) return None def _zurueck(lauf: dict, vmid: int, rueckweg: tuple[str, str]) -> str: """Den Rückweg gehen; Rückgabe: was geschah (für die Meldung). Wirft RuntimeError, wenn er scheitert.""" art, name = rueckweg if art == "snapshot": _auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S) return f"automatisch zurück auf den Snapshot {name}" _auftrag(lauf, "sicherung_zurueck", {"vmid": vmid, "sicherung": name}, ZEITLIMIT_SICHERUNG_S) return f"automatisch die Sicherung von vorher zurückgespielt ({name.rsplit('/', 1)[-1]})" def _gast_lauf(lauf: dict, gast: dict) -> None: vmid, baustein = gast["vmid"], lauf["baustein"] try: rueckweg = _rueckweg_anlegen(lauf, gast) except RuntimeError as exc: _ende(lauf, "fehler", f"{lauf['name']}: Update nicht begonnen — {str(exc).rstrip('.')}. " "Am Gerät wurde nichts geändert.") return try: _auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, ZEITLIMIT_UPDATE_S) time.sleep(WARTEN_NACH_UPDATE_S) nachher = _frischer_gast(lauf, vmid) if baustein == "app" and nachher: lauf["version_neu"] = (nachher.get("app") or {}).get("version") # fürs Protokoll fehler = pruefen(gast, nachher, baustein) except RuntimeError as exc: fehler = [str(exc)] if not fehler: _aufraeumen(lauf, vmid, rueckweg) _ende(lauf, "eingespielt", f"{lauf['name']}: eingespielt, Prüfung grün.") return if rueckweg: try: wie = _zurueck(lauf, vmid, rueckweg) _ende(lauf, "zurueckgerollt", f"{lauf['name']}: Update gescheitert ({' '.join(fehler)}) — {wie}. Läuft wieder wie vorher.", dringend=True) return except RuntimeError as exc: fehler.append(f"Auch der Rückweg scheiterte: {exc}") if rueckweg[0] == "sicherung": fehler.append(f"Die Sicherung {rueckweg[1]} liegt weiter auf dem Proxmox-Host.") _ende(lauf, "fehler", f"{lauf['name']}: Update gescheitert — {' '.join(fehler)}" + ("" if rueckweg else " Es gab weder Snapshot noch Sicherung, also keinen automatischen " "Rückweg."), dringend=True) def _probe_lauf(lauf: dict, gast: dict) -> None: """Rückweg testen: anlegen, absichtlich rot werten, zurückgehen, nachprüfen. Der Snapshot bzw. die Sicherung bleibt danach liegen (wie nach einem echten Rückweg) — wegräumen geht auf der Seite Snapshots.""" vmid = gast["vmid"] try: rueckweg = _rueckweg_anlegen(lauf, gast) except RuntimeError as exc: _ende(lauf, "fehler", f"{lauf['name']}: Rückweg-Probe nicht begonnen — {str(exc).rstrip('.')}. " "Am Gerät wurde nichts geändert.") return if not rueckweg: _ende(lauf, "fehler", f"{lauf['name']}: Für dieses Gerät gibt es keinen Rückweg (weder Snapshot noch " "Sicherung) — die Probe ist nicht möglich.") return lauf["schritte"].append("Prüfung absichtlich auf Rot gesetzt (Probe, kein Update).") _merken(lauf) try: wie = _zurueck(lauf, vmid, rueckweg) time.sleep(WARTEN_NACH_UPDATE_S) fehler = pruefen(gast, _frischer_gast(lauf, vmid), "probe") except RuntimeError as exc: wie, fehler = "", [str(exc)] if fehler: _ende(lauf, "fehler", f"{lauf['name']}: Rückweg-Probe rot — {' '.join(fehler)}", dringend=True) return _ende(lauf, "probe-gruen", f"{lauf['name']}: Rückweg-Probe grün — {wie}, danach läuft das Gerät und antwortet.") def _aufraeumen(lauf: dict, vmid: int, rueckweg: tuple[str, str] | None) -> None: """Ältere Snapshots des Orchestrators für diesen Gast löschen, nach einer Sicherung auch ältere Sicherungen; der Rückweg dieses Laufs (der neueste) bleibt. Ebenso bleiben Snapshots, die jemand auf der Seite Snapshots per Knopf angelegt hat (seit 25.09.2026): Die löscht nur der User.""" from services.homelab import snapshots # snapshots importiert dieses Modul art, behalten = rueckweg or (None, None) gast = inventar.gast(lauf["ziel"]) or {} per_knopf = snapshots.knopf_namen(lauf["ziel"], gast) for name in gast.get("snapshots") or []: if name.startswith("mc2-") and name != behalten and name not in per_knopf: try: _auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S) except RuntimeError: lauf["schritte"].append(f"Alter Snapshot {name} blieb stehen.") if art != "sicherung": return for volid in gast.get("sicherungen") or []: if volid != behalten: try: _auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, ZEITLIMIT_KURZ_S) except RuntimeError: lauf["schritte"].append(f"Alte Sicherung {volid} blieb stehen.") def _antwortet_wieder(url: str) -> bool: """Nach dem Update starten Container neu: Arcane darf sich ARCANE_FRIST_S lang sammeln, bevor es rot heißt.""" ende = time.monotonic() + ARCANE_FRIST_S while not inventar.erreichbar_frisch(url): if time.monotonic() >= ende: return False time.sleep(10) return True def _docker_lauf(lauf: dict, basis: str, gast: dict) -> None: """Docker über Arcanes Updater — ein Probelauf, bis der Schalter in den Einstellungen auf „echt“ steht (User-Entscheid 24.09.2026). Echt wie bei den Containern: vorher ein Snapshot der VM, wenn der Ausführer sie anfassen darf (Etikett watcher); ist die Prüfung danach rot, geht es darauf zurück.""" probe = not arcane.echt() rueckweg = None if not probe and gast.get("erlaubt"): try: rueckweg = _rueckweg_anlegen(lauf, gast) except RuntimeError as exc: _ende(lauf, "fehler", f"Arcane: Update nicht begonnen — {str(exc).rstrip('.')}. " "Am Gerät wurde nichts geändert.") return try: ergebnisse = arcane.updater(basis, probelauf=probe) except Exception as exc: if probe: _ende(lauf, "fehler", f"Arcane: Probelauf gescheitert — {exc}") return ergebnisse, fehler = {}, [f"Der Updater brach ab ({exc})."] else: fehler = [] gesamt = {k: sum(int((e or {}).get(k) or 0) for e in ergebnisse.values()) for k in ("checked", "updated", "failed")} namen = [str(i.get("resourceName")) for e in ergebnisse.values() for i in (e or {}).get("items") or [] if isinstance(i, dict) and i.get("updateAvailable")] if probe: _ende(lauf, "offen", f"Arcane-Probelauf: {len(namen)} von {gesamt['checked']} Containern würden aktualisiert" + (f" ({', '.join(namen[:8])})" if namen else "") + ". Geändert wurde nichts.") return if gesamt["failed"]: fehler.append(f"{gesamt['failed']} Container ließen sich nicht aktualisieren, {gesamt['updated']} schon.") if not _antwortet_wieder(basis + "/"): fehler.append("Arcane antwortet danach nicht.") if not fehler: if rueckweg: _aufraeumen(lauf, gast["vmid"], rueckweg) # Anders als bei den Containern bleibt auch dieser nicht stehen: Die VM schreibt laufend (Docker, Rippy), # ein Snapshot wüchse im Thin-Pool bis zum nächsten Update mit. Sein Zweck war der Weg zurück bei Rot. try: _auftrag(lauf, "snapshot_loeschen", {"vmid": gast["vmid"], "snapshot": rueckweg[1]}, ZEITLIMIT_KURZ_S) except RuntimeError: lauf["schritte"].append(f"Snapshot {rueckweg[1]} blieb stehen.") _ende(lauf, "eingespielt", f"Arcane: {gesamt['updated']} Container aktualisiert, Prüfung grün.") return if rueckweg: try: wie = _zurueck(lauf, gast["vmid"], rueckweg) _ende(lauf, "zurueckgerollt", f"Arcane: Update gescheitert ({' '.join(fehler)}) — {wie}. " "Läuft wieder wie vorher.", dringend=True) return except RuntimeError as exc: fehler.append(f"Auch der Rückweg scheiterte: {exc}") _ende(lauf, "fehler", f"Arcane: Update gescheitert — {' '.join(fehler)}" + ("" if rueckweg else " Es gab keinen Snapshot, also keinen automatischen Rückweg."), dringend=True) def _host_lauf(lauf: dict) -> None: try: if lauf["baustein"] == "kernel": # Welche Kernel gehen, bestimmt der Ausführer selbst (nie der laufende oder der nächste). a = _auftrag(lauf, "kernel_aufraeumen", {}, ZEITLIMIT_SICHERUNG_S) werte = dict(z.split("=", 1) for z in str(a.get("text") or "").splitlines()[:2] if "=" in z) anzahl, frei = int(werte.get("entfernt") or 0), int(werte.get("frei") or 0) _auftrag(lauf, "bericht", {}, 5 * 60) _ende(lauf, "aufgeraeumt", f"Proxmox-Host: {anzahl} alte Kernel entfernt, " f"{frei / 1e9:.1f} GB frei geworden.".replace(".", ",", 1) if anzahl else "Proxmox-Host: kein alter Kernel zu entfernen.") return if lauf["baustein"] == "neustart": _auftrag(lauf, "host_neustart", {}, 60) _ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.") return _auftrag(lauf, "host_update", {}, 60 * 60) _auftrag(lauf, "bericht", {}, 5 * 60) _ende(lauf, "eingespielt", "Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf.") except RuntimeError as exc: was = "Kernel aufräumen" if lauf["baustein"] == "kernel" else "Update" _ende(lauf, "fehler", f"Proxmox-Host: {was} gescheitert — {exc}", dringend=True) def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None: # Schritt von „Alle aktualisieren“: Den Erfolg sagt die Sammelmeldung am Ende; Fehler gehen sofort raus. still = bool(lauf.get("sammellauf")) and ergebnis == "eingespielt" and not dringend lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend, gemeldet=not still) _merken(lauf) # Eine Rückweg-Probe ist kein Update: Sie steht im Protokoll, nicht im Update-Verlauf. if lauf.get("baustein") != "probe": try: # Die Schritte eines Sammellaufs stehen im Update-Verlauf als EIN Lauf (gleicher Beginn, eigener Anlass). start = datetime.fromtimestamp(lauf.get("sammellauf_start") or lauf["start"], LOCAL_TZ) anlass = "Alle aktualisieren" if lauf.get("sammellauf") else "Update von Hand" update_verlauf.eintragen(start.isoformat(timespec="seconds"), anlass, lauf["ziel"], ergebnis, text) except Exception: log.warning("homelab: Verlauf nicht geschrieben", exc_info=True) if not still: _melden(text, dringend=dringend) def _neuer_lauf(ziel_id: str, baustein: str, name: str, sammellauf: dict | None = None, **mehr: object) -> dict: lauf = {"id": uuid.uuid4().hex[:12], "ziel": ziel_id, "baustein": baustein, "name": name, "status": "laeuft", "start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": [], **mehr} if sammellauf: lauf.update(sammellauf=sammellauf["id"], sammellauf_start=sammellauf["start"]) _merken(lauf) return lauf def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict: """Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}. sammellauf ({"id", "start"}) setzt nur „Alle aktualisieren“ (sammellauf.py) für seine Schritte; ohne ihn lehnt der Knopf ab, solange ein Sammellauf läuft.""" if baustein not in ("app", "os", "pakete", "neustart", "docker", "kernel", "probe"): return {"ok": False, "detail": "Unbekannter Baustein."} with START_SPERRE: if sammellauf is None and _sammellauf_laeuft(): return {"ok": False, "detail": SAMMELLAUF_SPERRE} if laeuft(ziel_id): return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."} if ziel_id == "pve" and baustein == "probe": return {"ok": False, "detail": "Für den Proxmox-Host gibt es keinen Rückweg zum Testen."} if ziel_id == "pve": name, gast = "Proxmox-Host", None host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {} mehr = ({"pakete": len(host.get("updates") or [])} if baustein == "pakete" else {"kernel": len(host.get("kernel_alt") or [])} if baustein == "kernel" else {}) else: gast = inventar.gast(ziel_id) if not gast: return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."} if baustein == "docker": app = apps.app_fuer(None, gast.get("name")) basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/") if not (app and app.kennung == "arcane" and basis and arcane.schluessel()): return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."} lauf = _neuer_lauf(ziel_id, baustein, app.name, sammellauf) threading.Thread(target=_docker_lauf, args=(lauf, basis, gast), name=f"homelab-{ziel_id}", daemon=True).start() return {"ok": True, "lauf": lauf["id"]} if not gast.get("erlaubt"): return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."} app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name")) name = app.name if app else str(gast.get("name")) if baustein == "probe": if not (gast.get("snapshot_moeglich") or gast.get("sicherung_moeglich")): return {"ok": False, "detail": f"{name} hat keinen Rückweg (weder Snapshot noch Sicherung)."} lauf = _neuer_lauf(ziel_id, baustein, name, None) threading.Thread(target=_probe_lauf, args=(lauf, gast), name=f"homelab-{ziel_id}", daemon=True).start() return {"ok": True, "lauf": lauf["id"]} if baustein == "app" and (not app or app.weg != "skript"): return {"ok": False, "detail": f"{name} aktualisiert sich nicht über das Community-Script " "(eigene Oberfläche oder Pakete)."} if baustein == "app" and (sperre := karenz.pruefen(app.kennung)["gesperrt"]): return {"ok": False, "detail": sperre} # Was vorher war, fürs Protokoll: die App-Version bzw. die Zahl der Pakete. mehr = ({"version_alt": (gast.get("app") or {}).get("version")} if baustein == "app" else {"pakete": (gast.get("os_updates") or {}).get("anzahl")}) lauf = _neuer_lauf(ziel_id, baustein, name, sammellauf, **mehr) ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast)) threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start() return {"ok": True, "lauf": lauf["id"]}