phase3+4: Homelab-Teil (Ausfuehrer, Ziele, Jetzt updaten mit Rueckweg) und Seite Homelab fuer alle Geraete

- deploy/homelab/ausfuehrer.py: laeuft als root auf dem Proxmox-Host, holt Auftraege beim Homelab-Teil
  ab (Pull, kein offener Port), feste Aktionsliste, prueft Etiketten selbst; Bericht gegen den echten
  Host erprobt (nur lesend). Kein Proxmox-Schluessel im Container noetig.
- services/homelab: Kanal mit gemeinsamem Geheimnis, App-Katalog, Inventar -> Ziele im gemeinsamen
  Modell (GitHub-Versionen, Webpruefung, alte Paketlisten = unklar), Jetzt updaten: Snapshot ->
  Update -> Pruefung -> bei Rot zurueck + dringende Meldung
- Waechter in der Rolle homelab: Ausfuehrer schweigt, Gaeste antworten nicht
- kern/github.py fuer beide Rollen (auch untagged Releases mit Version im Namen)
- Oberflaeche: Seite Homelab zeigt alle Geraete als Karten (KI-Box ueber /api/ziele, Homelab ueber
  /api/homelab/ziele) mit Stand, Rueckweg und Knopf samt Rueckfrage
- Einrichtung als Skripte (container-anlegen, ausrollen, ausfuehrer-einrichten, box-partner) — noch
  nicht ausgefuehrt, jeder Schritt braucht das User-OK
- frontend-bauen-box.sh: Frontend-Pruefung und Build auf der Box, wenn der PC keinen Speicher hat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 18:39:59 +02:00
co-authored by Claude Opus 5.5
parent a47489fa85
commit 5e9227c4a3
66 changed files with 2472 additions and 170 deletions
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# frontend-bauen-box.sh — Frontend-Prüfungen und Build auf der KI-Box statt am PC (24.09.2026).
#
# Der PC hatte zu wenig freien Speicher (Spiel lief nebenher), tsc und eslint brachen mit „heap out of
# memory" ab. Die Box hat genug davon und einen Node (den Hermes für seine Oberfläche mitbringt).
# Geschickt wird der Arbeitsstand von frontend/ (ohne node_modules und dist), zurück kommt dist/.
#
# Nutzung (Git-Bash am PC, im Repo): bash deploy/frontend-bauen-box.sh
# Ändert auf der Box nichts außer einem Wegwerf-Ordner unter /tmp.
set -euo pipefail
cd "$(dirname "$0")/.."
BOX="${MC_BOX:-hitonabi@192.168.178.151}"
ORDNER="/tmp/mc2-frontend-$$"
echo "Frontend-Prüfung und Build auf $BOX …"
tar --exclude=node_modules --exclude=dist -cf - frontend \
| ssh -o BatchMode=yes "$BOX" "rm -rf $ORDNER && mkdir -p $ORDNER && tar -x -C $ORDNER"
# shellcheck disable=SC2029 # $ORDNER soll hier (am PC) eingesetzt werden
ssh -o BatchMode=yes "$BOX" "set -e; export PATH=\$HOME/.hermes/node/bin:\$PATH; cd $ORDNER/frontend
npm ci --no-audit --no-fund --loglevel=error
echo '── Lint'; npm run --silent lint
echo '── Typen'; npx tsc --noEmit
echo '── Tests'; npm test --silent
echo '── Build'; npm run --silent build"
rm -rf frontend/dist
ssh -o BatchMode=yes "$BOX" "tar -cf - -C $ORDNER/frontend dist && rm -rf $ORDNER" | tar -x -C frontend
echo "✅ frontend/dist ist neu gebaut ($(du -sh frontend/dist | cut -f1))."
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# ausfuehrer-einrichten.sh — den Ausführer auf dem Proxmox-Host einrichten (Phase 3/4).
#
# Läuft am PC (Git-Bash, im Repo). Legt ausfuehrer.py, die Unit und die Konfiguration auf den
# Proxmox-Host und startet den Dienst. Das gemeinsame Geheimnis wandert vom Container des Homelab-Teils
# per Pipe direkt auf den Host (0600) — es steht in keiner Befehlszeile.
#
# Nutzung: bash deploy/homelab/ausfuehrer-einrichten.sh <ip-des-containers>
set -euo pipefail
cd "$(dirname "$0")/../.."
IP="${1:?Nutzung: ausfuehrer-einrichten.sh <ip-des-containers>}"
PVE="${MC_PVE:-pve}"
ssh -o BatchMode=yes "$PVE" "install -d -m 755 /usr/local/lib/mc2"
ssh -o BatchMode=yes "$PVE" "cat > /usr/local/lib/mc2/ausfuehrer.py && chmod 755 /usr/local/lib/mc2/ausfuehrer.py" \
< deploy/homelab/ausfuehrer.py
ssh -o BatchMode=yes "$PVE" "cat > /etc/systemd/system/mc2-ausfuehrer.service" < deploy/homelab/mc2-ausfuehrer.service
# shellcheck disable=SC2029 # $IP soll hier (am PC) eingesetzt werden
ssh -o BatchMode=yes "root@$IP" "cat /var/lib/mc2/ausfuehrer.token" \
| ssh -o BatchMode=yes "$PVE" "umask 077; read -r t; [ -n \"\$t\" ] || exit 1; \
printf '{\"server\": \"http://%s:9001\", \"token\": \"%s\", \"node\": \"%s\"}\n' '$IP' \"\$t\" \"\$(hostname)\" \
> /etc/mc2-ausfuehrer.json"
ssh -o BatchMode=yes "$PVE" "systemctl daemon-reload && systemctl enable --now mc2-ausfuehrer.service \
&& systemctl restart mc2-ausfuehrer.service && sleep 5 && systemctl is-active mc2-ausfuehrer.service"
echo "✅ Ausführer läuft. Der erste Bericht erscheint in etwa einer Minute unter /api/homelab/ziele."
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""Ausführer des Homelab Orchestrators auf dem Proxmox-Host (Phase 3/4, 24.09.2026).
Läuft als root auf dem Proxmox-PC (mc2-ausfuehrer.service) und holt sich seine Arbeit selbst
beim Homelab-Teil ab („Pull“, User-Entscheid 24.09.): Der Proxmox-Host öffnet keinen Port, und der
Container des Homelab-Teils braucht keinen Proxmox-Schlüssel.
• Bericht alle 10 Minuten (oder auf Anfrage): Host-Updates, Gäste mit Status, App-Version,
OS-Updates, Snapshots und ob ein Snapshot überhaupt geht. Nur lesend.
• Aufträge eine feste Liste von Aktionen (AKTIONEN); alles andere wird abgelehnt. Welche Gäste
angefasst werden dürfen, entscheidet dieser Host selbst anhand der Etiketten:
community-script oder watcher, aber nicht watcher-aus. Dem Server wird dabei nicht
vertraut — er kann nur aus der Liste wählen.
Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json
{"server": "http://192.168.178.x:9001", "token": "<gemeinsames Geheimnis>", "node": "pve"}
Aufruf: ausfuehrer.py Dauerbetrieb (systemd)
ausfuehrer.py --bericht Bericht einmal auf die Konsole (nur lesend, zum Prüfen)
"""
import argparse
import json
import logging
import os
import platform
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
KONFIG = os.environ.get("MC2_AUSFUEHRER_KONFIG", "/etc/mc2-ausfuehrer.json")
BERICHT_ALLE_S = 600
ABFRAGE_ALLE_S = 5
ERLAUBT = {"community-script", "watcher"}
AUSGENOMMEN = "watcher-aus"
SNAPSHOT_NAME = re.compile(r"^mc2-\d{8}-\d{6}$")
# Speicherarten, auf denen Proxmox Snapshots kann.
SNAPSHOT_SPEICHER = {"lvmthin", "zfspool", "rbd", "btrfs", "cephfs"}
# App-Version je Community-Script (Kennung aus /usr/bin/update im Gast). Neuere Skripte legen die Version
# in /root/.<app> ab; für die älteren steht hier, wie man sie erfährt. Festes Wissen dieses Hosts.
VERSION_PROBEN = {
"adguard": "/opt/AdGuardHome/AdGuardHome --version",
"netbird": "netbird version",
"proxmox-backup-server": "dpkg-query -W proxmox-backup-server",
"npmplus": "docker image inspect zoeyvid/npmplus:latest --format '{{.Created}}'",
}
VERSIONSDATEIEN = {"pve-scripts-local": "/root/.proxmoxve-local"}
log = logging.getLogger("ausfuehrer")
# --- Hilfen -----------------------------------------------------------------------------
def _laufen(befehl: list[str], zeitlimit: int = 60) -> tuple[int, str]:
try:
r = subprocess.run(befehl, capture_output=True, text=True, timeout=zeitlimit)
except subprocess.TimeoutExpired:
return 124, f"Zeitlimit ({zeitlimit} s) überschritten"
except OSError as exc:
return 127, str(exc)
return r.returncode, (r.stdout + r.stderr).strip()
def _pvesh(pfad: str, *parameter: str) -> object:
code, text = _laufen(["pvesh", "get", pfad, *parameter, "--output-format", "json"], 30)
if code != 0:
raise RuntimeError(f"pvesh {pfad}: {text[:200]}")
return json.loads(text or "null")
def _im_gast(vmid: int, befehl: str, zeitlimit: int = 30) -> tuple[int, str]:
return _laufen(["pct", "exec", str(vmid), "--", "sh", "-c", befehl], zeitlimit)
def _etiketten(roh: str | None) -> list[str]:
return [t for t in (roh or "").replace(",", ";").split(";") if t]
def erlaubt(etiketten: list[str]) -> bool:
menge = set(etiketten)
return bool(menge & ERLAUBT) and AUSGENOMMEN not in menge
# --- Bericht (nur lesend) -------------------------------------------------------------------
def _speicherart() -> dict[str, str]:
try:
return {s["storage"]: s["type"] for s in _pvesh(f"/nodes/{NODE}/storage")}
except Exception:
return {}
def snapshot_moeglich(konfig: dict, arten: dict[str, str]) -> tuple[bool, str | None]:
"""Geht ein Snapshot? Bind-Mounts (mpX mit Host-Pfad) und Speicher ohne Snapshots verhindern ihn."""
for schluessel, wert in konfig.items():
if re.fullmatch(r"mp\d+", schluessel) and str(wert).split(",")[0].startswith("/"):
return False, f"Bind-Mount {schluessel} ({str(wert).split(',')[0]}) — nur Backup möglich"
for schluessel in ("rootfs", "scsi0", "virtio0", "sata0", "ide0"):
if schluessel in konfig:
speicher = str(konfig[schluessel]).split(":")[0]
art = arten.get(speicher)
if art and art not in SNAPSHOT_SPEICHER:
return False, f"Speicher {speicher} ({art}) kann keine Snapshots"
return True, None
def _app_kennung(vmid: int) -> str | None:
"""Welches Community-Script steckt im Gast? /usr/bin/update lädt ct/<kennung>.sh."""
code, text = _im_gast(vmid, "cat /usr/bin/update 2>/dev/null", 10)
m = re.search(r"/ct/([a-z0-9-]+)\.sh", text) if code == 0 else None
return m.group(1) if m else None
def _app_version(vmid: int, kennung: str) -> str | None:
befehl = VERSION_PROBEN.get(kennung)
if befehl is None:
datei = VERSIONSDATEIEN.get(kennung, f"/root/.{kennung}")
befehl = f"cat {datei} 2>/dev/null"
code, text = _im_gast(vmid, befehl, 20)
if code != 0 or not text:
return None
zeile = text.splitlines()[-1].strip()
# Docker-Apps (NPMplus) haben keine Versionsnummer, nur das Datum ihres Images.
if m := re.match(r"(\d{4}-\d{2}-\d{2})T", zeile):
return f"Image vom {m.group(1)}"
m = re.search(r"v?(\d+(?:\.\d+){1,3}(?:[-+][\w.]+)?)", zeile)
return m.group(1) if m else zeile[:40]
def _os_updates(vmid: int, ostype: str) -> dict:
"""Aktualisierbare Pakete nach den vorhandenen Paketlisten (liest nur) und wie alt die Listen sind."""
if ostype == "alpine":
code, text = _im_gast(vmid, "apk version -l '<' 2>/dev/null | tail -n +2 | wc -l; "
"stat -c %Y /var/cache/apk 2>/dev/null || echo 0", 30)
else:
code, text = _im_gast(vmid, "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable; "
"stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0", 30)
zeilen = text.split() if code in (0, 1) else []
try:
return {"anzahl": int(zeilen[0]), "listen_stand": int(zeilen[1]) or None}
except (IndexError, ValueError):
return {"anzahl": None, "listen_stand": None, "fehler": text[:160]}
def _ip_lxc(vmid: int) -> str | None:
try:
for schnitt in _pvesh(f"/nodes/{NODE}/lxc/{vmid}/interfaces"):
if schnitt.get("name") in ("eth0", "ens18"):
for adresse in schnitt.get("ip-addresses", []):
if adresse.get("ip-address-type") == "inet":
return adresse["ip-address"].split("/")[0]
except Exception:
pass
return None
def _ip_vm(vmid: int) -> str | None:
try:
daten = _pvesh(f"/nodes/{NODE}/qemu/{vmid}/agent/network-get-interfaces")
for schnitt in (daten or {}).get("result", []):
if schnitt.get("name") == "lo":
continue
for adresse in schnitt.get("ip-addresses", []):
ip = adresse.get("ip-address", "")
if adresse.get("ip-address-type") == "ipv4" and not ip.startswith(("127.", "172.")):
return ip
except Exception:
pass
return None
def _snapshots(art: str, vmid: int) -> list[str]:
try:
return [s["name"] for s in _pvesh(f"/nodes/{NODE}/{art}/{vmid}/snapshot") if s.get("name") != "current"]
except Exception:
return []
def _gast(eintrag: dict, arten: dict[str, str]) -> dict:
art, vmid = eintrag["type"], int(eintrag["vmid"])
etiketten = _etiketten(eintrag.get("tags"))
gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"),
"etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"),
"snapshots": _snapshots(art, vmid)}
try:
konfig = _pvesh(f"/nodes/{NODE}/{art}/{vmid}/config")
except Exception as exc:
gast["fehler"] = str(exc)[:200]
return gast
gast["onboot"] = bool(konfig.get("onboot"))
gast["snapshot_moeglich"], gast["snapshot_grund"] = snapshot_moeglich(konfig, arten)
if art == "lxc":
gast["ostype"] = konfig.get("ostype")
gast["ip"] = _ip_lxc(vmid) if gast["status"] == "running" else None
if gast["status"] == "running" and gast["erlaubt"]:
kennung = _app_kennung(vmid)
gast["app"] = {"kennung": kennung, "version": _app_version(vmid, kennung) if kennung else None}
gast["os_updates"] = _os_updates(vmid, str(konfig.get("ostype") or "debian"))
else:
gast["ip"] = _ip_vm(vmid) if gast["status"] == "running" else None
return gast
def _host() -> dict:
host: dict = {"node": NODE, "neustart_noetig": os.path.exists("/var/run/reboot-required")}
try:
host["version"] = _pvesh("/version").get("version")
host["updates"] = [{"paket": p.get("Package"), "alt": p.get("OldVersion"), "neu": p.get("Version"),
"herkunft": p.get("Origin")} for p in _pvesh(f"/nodes/{NODE}/apt/update")]
except Exception as exc:
host["fehler"] = str(exc)[:200]
code, kernel = _laufen(["uname", "-r"], 5)
host["kernel"] = kernel if code == 0 else None
return host
def bericht() -> dict:
arten = _speicherart()
gaeste = []
for eintrag in _pvesh("/cluster/resources", "--type", "vm") or []:
if eintrag.get("node") == NODE and not eintrag.get("template"):
gaeste.append(_gast(eintrag, arten))
return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": _host(),
"gaeste": sorted(gaeste, key=lambda g: g["vmid"])}
# --- Aufträge (feste Liste) ---------------------------------------------------------------
def _gast_pruefen(vmid: object) -> tuple[str, int]:
"""Gibt (art, vmid) zurück, wenn dieser Host den Gast anfassen darf — sonst ValueError."""
if not isinstance(vmid, int) or not 100 <= vmid <= 999_999_999:
raise ValueError("ungültige Gast-Nummer")
for eintrag in _pvesh("/cluster/resources", "--type", "vm") or []:
if int(eintrag.get("vmid", -1)) == vmid:
if not erlaubt(_etiketten(eintrag.get("tags"))):
raise ValueError(f"Gast {vmid} hat kein erlaubtes Etikett (community-script/watcher) "
f"oder ist mit {AUSGENOMMEN} ausgenommen")
return eintrag["type"], vmid
raise ValueError(f"Gast {vmid} gibt es nicht")
def _werkzeug(art: str) -> str:
return "pct" if art == "lxc" else "qm"
def a_snapshot(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid"))
name = "mc2-" + datetime.now().strftime("%Y%m%d-%H%M%S")
code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description",
"Vor einem Update durch den Homelab Orchestrator"], 600)
return code, f"snapshot={name}\n{text}" if code == 0 else text
def a_update(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid"))
if art != "lxc":
raise ValueError("Update per Community-Script gibt es nur für Container")
# PHS_SILENT=1: das Community-Script fragt nichts. Es lädt ct/<app>.sh live von GitHub (main).
return _im_gast(vmid, "PHS_SILENT=1 bash /usr/bin/update", int(p.get("zeitlimit") or 1800))
def a_suchen(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid"))
if art != "lxc":
raise ValueError("nur für Container")
return _im_gast(vmid, "if command -v apk >/dev/null; then apk update; else apt-get update -q; fi", 300)
def a_os_update(p: dict) -> tuple[int, str]:
"""Die Pakete des Gasts (unabhängig vom App-Update des Community-Scripts)."""
art, vmid = _gast_pruefen(p.get("vmid"))
if art != "lxc":
raise ValueError("nur für Container")
befehl = ("if command -v apk >/dev/null; then apk update && apk upgrade; else apt-get update -q && "
"DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade; fi")
return _im_gast(vmid, befehl, int(p.get("zeitlimit") or 1800))
def a_zurueck(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid"))
name = str(p.get("snapshot") or "")
if not SNAPSHOT_NAME.match(name):
raise ValueError("nur Snapshots des Orchestrators (mc2-…) lassen sich zurückspielen")
code, text = _laufen([_werkzeug(art), "rollback", str(vmid), name], 900)
if code == 0:
code2, text2 = _laufen([_werkzeug(art), "start", str(vmid)], 120)
text += "\n" + text2
code = 0 if code2 == 0 or "already running" in text2 else code2
return code, text
def a_snapshot_loeschen(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid"))
name = str(p.get("snapshot") or "")
if not SNAPSHOT_NAME.match(name):
raise ValueError("nur Snapshots des Orchestrators (mc2-…) werden gelöscht")
return _laufen([_werkzeug(art), "delsnapshot", str(vmid), name], 600)
def a_host_update(p: dict) -> tuple[int, str]:
# Kein Neustart: der ist ein eigener Knopf (User-Entscheid 24.09.2026).
befehl = ("apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get -y "
"-o Dpkg::Options::=--force-confold dist-upgrade")
return _laufen(["bash", "-c", befehl], 3600)
def a_host_neustart(p: dict) -> tuple[int, str]:
return _laufen(["systemctl", "reboot"], 30)
def a_bericht(p: dict) -> tuple[int, str]:
return 0, json.dumps(bericht(), ensure_ascii=False)
AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update, "suchen": a_suchen,
"zurueck": a_zurueck,
"snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update,
"host_neustart": a_host_neustart, "bericht": a_bericht}
def ausfuehren(auftrag: dict) -> dict:
aktion = AKTIONEN.get(str(auftrag.get("aktion")))
if aktion is None:
return {"code": 2, "text": f"Unbekannte Aktion: {auftrag.get('aktion')!r}"}
try:
code, text = aktion(auftrag.get("parameter") or {})
except (ValueError, RuntimeError) as exc:
return {"code": 2, "text": f"Abgelehnt: {exc}"}
return {"code": code, "text": text[-20000:]}
# --- Verbindung zum Homelab-Teil -------------------------------------------------------------
def _anfrage(methode: str, pfad: str, daten: object = None) -> object:
roh = None if daten is None else json.dumps(daten).encode()
anfrage = urllib.request.Request(SERVER + pfad, data=roh, method=methode,
headers={"Content-Type": "application/json", "X-MC2-Ausfuehrer": TOKEN})
with urllib.request.urlopen(anfrage, timeout=30) as antwort:
text = antwort.read().decode() or "null"
return json.loads(text)
def dauerbetrieb() -> None:
naechster_bericht = 0.0
while True:
try:
if time.time() >= naechster_bericht:
_anfrage("POST", "/api/homelab/ausfuehrer/bericht", bericht())
naechster_bericht = time.time() + BERICHT_ALLE_S
auftrag = _anfrage("GET", "/api/homelab/ausfuehrer/auftrag")
if isinstance(auftrag, dict) and auftrag.get("id"):
log.info("Auftrag %s: %s %s", auftrag["id"], auftrag.get("aktion"), auftrag.get("parameter"))
ergebnis = ausfuehren(auftrag)
_anfrage("POST", f"/api/homelab/ausfuehrer/ergebnis/{auftrag['id']}", ergebnis)
if auftrag.get("aktion") != "bericht":
naechster_bericht = 0.0 # nach jeder Änderung einen frischen Bericht
continue
except (urllib.error.URLError, OSError, ValueError) as exc:
log.warning("Homelab-Teil nicht erreichbar: %s", exc)
except Exception:
log.exception("Unerwarteter Fehler")
time.sleep(ABFRAGE_ALLE_S)
def _konfig_laden() -> dict:
try:
with open(KONFIG, encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return {}
_konfig = _konfig_laden()
SERVER = str(_konfig.get("server") or "").rstrip("/")
TOKEN = str(_konfig.get("token") or "")
NODE = str(_konfig.get("node") or platform.node())
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
teile = argparse.ArgumentParser(description=__doc__.splitlines()[0])
teile.add_argument("--bericht", action="store_true", help="Bericht einmal ausgeben (nur lesend)")
args = teile.parse_args()
if args.bericht:
json.dump(bericht(), sys.stdout, ensure_ascii=False, indent=2)
print()
return 0
if not SERVER or not TOKEN:
log.error("Konfiguration %s fehlt oder ist unvollständig (server, token).", KONFIG)
return 1
dauerbetrieb()
return 0
if __name__ == "__main__":
sys.exit(main())
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# ausrollen.sh — den Homelab-Teil in seinen Container bringen (erstmals und bei jedem Update).
#
# Läuft am PC (Git-Bash, im Repo). Schickt den Stand von HEAD (backend, deploy, frontend/dist) per SSH in
# den Container, lässt einrichten.sh laufen und startet die Dienste neu. Der Container braucht so keinen
# Zugang zu Gitea. Vorher: bash deploy/pruefen.sh (und deploy/probelauf-box.sh).
#
# Nutzung: bash deploy/homelab/ausrollen.sh <ip-des-containers> [partner-url]
set -euo pipefail
cd "$(dirname "$0")/../.."
IP="${1:?Nutzung: ausrollen.sh <ip-des-containers> [partner-url]}"
PARTNER="${2:-http://192.168.178.151:9001}"
ZIEL="root@$IP"
echo "Rolle $(git rev-parse --short HEAD) nach $IP aus …"
git archive --format=tar HEAD backend deploy frontend/dist ruff.toml \
| ssh -o BatchMode=yes "$ZIEL" "mkdir -p /opt/mc2 && find /opt/mc2 -mindepth 1 -maxdepth 1 ! -name backend -exec rm -rf {} + \
&& find /opt/mc2/backend -mindepth 1 -maxdepth 1 ! -name .venv -exec rm -rf {} + 2>/dev/null; tar -x -C /opt/mc2"
ssh -o BatchMode=yes "$ZIEL" "bash /opt/mc2/deploy/homelab/einrichten.sh '$PARTNER'"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# box-partner.sh — der KI-Box sagen, wo der Homelab-Teil läuft (Phase 3).
#
# Läuft am PC (Git-Bash). Legt auf der Box Drop-ins für mission-control-2 und mc2-steward mit
# MC_PARTNER_URL an und startet beide neu. Die Drop-ins stehen nicht im Repo (die IP gehört zum Heimnetz),
# deploy.sh lässt sie stehen. Danach zeigt die Seite „Homelab“ die Geräte, und der Wächter der Box prüft
# den Homelab-Teil (und umgekehrt).
#
# Nutzung: bash deploy/homelab/box-partner.sh <ip-des-containers>
set -euo pipefail
IP="${1:?Nutzung: box-partner.sh <ip-des-containers>}"
BOX="${MC_BOX:-hitonabi@192.168.178.151}"
# shellcheck disable=SC2029 # $IP soll hier (am PC) eingesetzt werden
ssh -o BatchMode=yes "$BOX" "export XDG_RUNTIME_DIR=/run/user/\$(id -u); for u in mission-control-2 mc2-steward; do
mkdir -p ~/.config/systemd/user/\$u.service.d
printf '[Service]\nEnvironment=MC_PARTNER_URL=http://%s:9001\nEnvironment=MC_PARTNER_NAME=Homelab\n' '$IP' \
> ~/.config/systemd/user/\$u.service.d/partner.conf
done
systemctl --user daemon-reload && systemctl --user restart mission-control-2 mc2-steward
sleep 4; curl -s -m 10 http://127.0.0.1:9001/api/partner"
echo
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# container-anlegen.sh — den Container für den Homelab-Teil auf dem Proxmox-PC anlegen (Phase 3).
#
# Läuft am PC (Git-Bash) und spricht per SSH mit dem Proxmox-Host (Alias „pve“, Schlüssel id_lucy_infra).
# Legt EINEN unprivilegierten Debian-13-Container an: 1 Kern, 1 GB RAM, 4 GB Platte, DHCP, Autostart.
# Etikett „mc2“ — bewusst NICHT community-script/watcher: Der Orchestrator aktualisiert sich nicht selbst.
#
# Nutzung: bash deploy/homelab/container-anlegen.sh [vmid]
# Danach: bash deploy/homelab/ausrollen.sh <ip>
set -euo pipefail
PVE="${MC_PVE:-pve}"
VORLAGE="${MC_VORLAGE:-local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst}"
VMID="${1:-$(ssh -o BatchMode=yes "$PVE" pvesh get /cluster/nextid)}"
echo "Lege Container $VMID (homelab-orchestrator) auf $PVE an …"
ssh -o BatchMode=yes "$PVE" "pct create $VMID $VORLAGE \
--hostname homelab-orchestrator --description 'Homelab Orchestrator – Homelab-Teil (MC2, Rolle homelab)' \
--cores 1 --memory 1024 --swap 512 --rootfs local-lvm:4 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp --unprivileged 1 --features nesting=1 \
--onboot 1 --tags mc2 --timezone Europe/Berlin --start 1"
# Auf die IP warten (DHCP), dann den SSH-Schlüssel des PCs und der Box eintragen (wie bei allen Gästen).
for _ in $(seq 1 30); do
IP="$(ssh -o BatchMode=yes "$PVE" "pct exec $VMID -- hostname -I 2>/dev/null | awk '{print \$1}'" || true)"
[ -n "$IP" ] && break
sleep 2
done
[ -n "${IP:-}" ] || { echo "✗ Container $VMID hat keine IP bekommen."; exit 1; }
ssh -o BatchMode=yes "$PVE" "pct exec $VMID -- bash -c 'apt-get update -q && apt-get install -y -q openssh-server && \
mkdir -p /root/.ssh && chmod 700 /root/.ssh' && cat /root/.ssh/authorized_keys | pct exec $VMID -- bash -c \
'cat >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && systemctl enable --now ssh'"
echo "✅ Container $VMID läuft unter $IP. Weiter mit: bash deploy/homelab/ausrollen.sh $IP"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# einrichten.sh — läuft IM Container des Homelab-Teils als root (ausrollen.sh ruft es auf).
#
# Idempotent: Pakete, Dienst-Nutzer mc2, Ordner, Python-Umgebung, systemd-Units. Die Konfiguration
# /etc/mc2/homelab.env (MC_PARTNER_URL …) bleibt, wenn es sie schon gibt.
set -euo pipefail
ZIEL=/opt/mc2
PARTNER="${1:-http://192.168.178.151:9001}"
apt-get update -q
DEBIAN_FRONTEND=noninteractive apt-get install -y -q python3 python3-venv curl ca-certificates
id mc2 >/dev/null 2>&1 || useradd --system --home-dir /var/lib/mc2 --shell /usr/sbin/nologin mc2
install -d -o mc2 -g mc2 -m 750 /var/lib/mc2
install -d -o root -g mc2 -m 750 /etc/mc2
[ -f /etc/mc2/homelab.env ] || printf 'MC_PARTNER_URL=%s\nMC_PARTNER_NAME=Box-Wart\n' "$PARTNER" > /etc/mc2/homelab.env
chown root:mc2 /etc/mc2/homelab.env && chmod 640 /etc/mc2/homelab.env
python3 -m venv "$ZIEL/backend/.venv"
"$ZIEL/backend/.venv/bin/pip" install -q --upgrade pip
"$ZIEL/backend/.venv/bin/pip" install -q -r "$ZIEL/backend/requirements.txt"
chown -R root:root "$ZIEL"
install -m 644 "$ZIEL"/deploy/homelab/mc2-homelab*.service "$ZIEL"/deploy/homelab/mc2-homelab*.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now mc2-homelab.service mc2-homelab-steward.service mc2-homelab-morgenmeldung.timer
systemctl restart mc2-homelab.service mc2-homelab-steward.service
for _ in $(seq 1 20); do
curl -sf -m 3 http://127.0.0.1:9001/api/health >/dev/null && break
sleep 1
done
curl -sf -m 3 http://127.0.0.1:9001/api/health && echo
# Das gemeinsame Geheimnis für den Ausführer entsteht beim ersten Abruf (0600, Nutzer mc2).
curl -sf -m 5 http://127.0.0.1:9001/api/homelab/ausfuehrer >/dev/null || true
echo "✅ Homelab-Teil läuft."
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Homelab Orchestrator – Ausführer auf dem Proxmox-Host (holt Aufträge beim Homelab-Teil ab)
After=network-online.target pve-cluster.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/lib/mc2/ausfuehrer.py
Restart=always
RestartSec=15
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,13 @@
[Unit]
Description=Homelab Orchestrator – Morgenmeldung des Homelab-Teils (Nachtmeldungen als eine Nachricht)
[Service]
Type=oneshot
User=mc2
Group=mc2
Environment=MC_NOTIFY_HERMES=/bin/false
Environment=MC_TELEGRAM_ENV=/etc/mc2/telegram.env
Environment=MC_NOTIFY_LOG=/var/lib/mc2/notify.log
Environment=MC_NIGHT_QUEUE=/var/lib/mc2/night-queue.txt
Environment="MC_MORGENMELDUNG_BETREFF=[Morgenmeldung Homelab]"
ExecStart=/bin/bash /opt/mc2/deploy/morgenmeldung.sh
@@ -0,0 +1,9 @@
[Unit]
Description=Homelab Orchestrator – Morgenmeldung des Homelab-Teils um 07:00
[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=false
[Install]
WantedBy=timers.target
@@ -0,0 +1,24 @@
[Unit]
Description=Homelab Orchestrator – Wächter des Homelab-Teils (Ausführer, Gäste, Partner, Platte)
After=mc2-homelab.service
[Service]
Type=simple
User=mc2
Group=mc2
WorkingDirectory=/opt/mc2/backend
EnvironmentFile=/etc/mc2/homelab.env
Environment=MC_ROLLE=homelab
Environment=MC_DATEN_DIR=/var/lib/mc2
Environment=MC_NOTIFY_HERMES=/bin/false
Environment=MC_NOTIFY_NO_ANNOUNCE=1
Environment=MC_TELEGRAM_ENV=/etc/mc2/telegram.env
Environment=MC_NOTIFY_LOG=/var/lib/mc2/notify.log
Environment=MC_NIGHT_QUEUE=/var/lib/mc2/night-queue.txt
Environment=MC_SENTRY_MC2_URL=http://127.0.0.1:9001
ExecStart=/opt/mc2/backend/.venv/bin/python steward.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=Homelab Orchestrator – Homelab-Teil (Oberfläche und Schnittstellen, Rolle homelab)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mc2
Group=mc2
WorkingDirectory=/opt/mc2/backend
EnvironmentFile=/etc/mc2/homelab.env
Environment=MC_ROLLE=homelab
Environment=MC_DATEN_DIR=/var/lib/mc2
# Kein Hermes im Container: Meldungen gehen über den Zweitweg direkt an die Telegram-Bot-API.
Environment=MC_NOTIFY_HERMES=/bin/false
Environment=MC_NOTIFY_NO_ANNOUNCE=1
Environment=MC_TELEGRAM_ENV=/etc/mc2/telegram.env
Environment=MC_NOTIFY_LOG=/var/lib/mc2/notify.log
Environment=MC_NIGHT_QUEUE=/var/lib/mc2/night-queue.txt
Environment=MC_UPDATE_VERLAUF=/var/lib/mc2/mc2-update-verlauf.jsonl
ExecStart=/opt/mc2/backend/.venv/bin/uvicorn app:app --host 0.0.0.0 --port 9001 --timeout-graceful-shutdown 3
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+3 -1
View File
@@ -13,6 +13,8 @@ SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
MAX_ZEICHEN="${MC_MORGENMELDUNG_MAX:-3500}" # Telegram erlaubt 4096 Zeichen je Nachricht
VERSUCHE="${MC_MORGENMELDUNG_VERSUCHE:-3}"
PAUSE="${MC_MORGENMELDUNG_PAUSE:-60}"
# Der Homelab-Teil schickt seine eigene Morgenmeldung (mc2-homelab-morgenmeldung.service).
BETREFF="${MC_MORGENMELDUNG_BETREFF:-[Morgenmeldung]}"
# Melde-Log begrenzen (wuchs seit Juli ungebremst): über 3 MB bleiben die letzten 2 MB.
# services/update_verlauf.py liest ohnehin nur das Ende, das reicht für Monate Update-Verlauf.
@@ -44,7 +46,7 @@ for ((i = 1; i <= VERSUCHE; i++)); do
# Als 07:00 senden (außerhalb der Nachtruhe); NO_ANNOUNCE, weil jede Meldung schon einzeln im
# Briefkasten liegt.
if MC_NOTIFY_STRICT=1 MC_NOTIFY_NO_ANNOUNCE=1 MC_NOTIFY_STUNDE=07 \
bash "$SRC_DIR/notify.sh" -s "[Morgenmeldung]" "$NACHRICHT"; then
bash "$SRC_DIR/notify.sh" -s "$BETREFF" "$NACHRICHT"; then
mv -f "$ARBEIT" "$QUEUE.zuletzt-gesendet"
exit 0
fi
+1 -1
View File
@@ -20,7 +20,7 @@ fehler=0
schritt() { echo "── $1"; }
schritt "Syntax der Shell-Skripte"
for f in deploy/*.sh deploy/jobs/*.sh; do
for f in deploy/*.sh deploy/jobs/*.sh deploy/homelab/*.sh; do
bash -n "$f" || { echo " ✗ $f"; fehler=1; }
done