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:
co-authored by
Claude Opus 5.5
parent
a47489fa85
commit
5e9227c4a3
@@ -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."
|
||||
@@ -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())
|
||||
@@ -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'"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user