Später · 20 Konfig-Sicherung ins private Gitea-Repo, Geheimnisse geschwärzt
- kern/schwaerzen.py + dieselben Regeln im Ausführer (Test vergleicht beide): private Schlüssel, Werte hinter password/secret/token/key…, Passwort-Hashes, htpasswd, Zugangsdaten in Adressen, Authorization-Kopfzeilen, Token-Formate, lange Hex-Ketten - Ausführer: sammelt Proxmox (Gäste, Speicher, Aufträge, Netz), AdGuard, NPMplus (ohne tls/), schwärzt auf dem Host, sendet alle 6 h und auf Abruf (Aktion konfig_senden) an POST /api/homelab/ausfuehrer/konfig - Homelab-Teil: GET /api/homelab/konfig (frisch=1), dazu Compose und .env der Arcane-Projekte, geschwärzt - KI-Box: services/konfig_sicherung.py legt das Repo beim ersten Lauf privat an, Arbeitskopie je Lauf auf Gitea-Stand, Commit nur bei Änderungen; Timer täglich 02:30, Knopf „Jetzt sichern“ unter Einstellungen → Homelab - Totmannschalter und Wächter kennen den Timer; Doku Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2972cdfeca
commit
75c6037431
@@ -54,6 +54,7 @@ ZEITPLAENE: tuple[Zeitplan, ...] = (
|
||||
Zeitplan("mc2-probe-wiederherstellung", "Probe-Wiederherstellung", 35 * TAG, "erster Montag im Monat 05:15"),
|
||||
Zeitplan("projekte-sync", "Projekte-Abgleich", 3 * STUNDE, "stündlich"),
|
||||
Zeitplan("mc2-wochenbericht", "Wochenbericht", 7 * TAG + 6 * STUNDE, "sonntags 07:30"),
|
||||
Zeitplan("mc2-konfig-sicherung", "Konfig-Sicherung", TAG + 3 * STUNDE, "täglich 02:30"),
|
||||
)
|
||||
HERMES_KARENZ_S = STUNDE
|
||||
CACHE_S = 300 # der Wächter fragt jede Minute; die Zeitpläne ändern sich nicht so schnell
|
||||
|
||||
@@ -34,7 +34,7 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
|
||||
|
||||
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
|
||||
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen",
|
||||
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen", "changelog", "journal"}
|
||||
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen", "changelog", "journal", "konfig_senden"}
|
||||
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
|
||||
BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60)
|
||||
VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Konfig-Sicherung — der Anteil des Homelab-Teils (Ausbauplan „Später“, Punkt 20, seit 26.09.2026).
|
||||
|
||||
Der Ausführer sammelt die Einstellungen von Proxmox, AdGuard und NPMplus, schwärzt sie schon auf dem Host und schickt
|
||||
sie alle sechs Stunden (und auf Abruf, Aktion konfig_senden) an POST /api/homelab/ausfuehrer/konfig; hier liegen sie
|
||||
in <Datenordner>/konfig-host.json (0600). Dazu kommen die Compose-Dateien und .env der Arcane-Projekte über Arcanes
|
||||
Schnittstelle, geschwärzt mit kern/schwaerzen.py. Die KI-Box holt beides über GET /api/homelab/konfig und legt es in
|
||||
ein privates Gitea-Repo (services/konfig_sicherung.py) — so braucht dieser Container keinen Gitea-Zugang.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.schwaerzen import schwaerzen
|
||||
|
||||
from services.homelab import arcane, kanal
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
AUFFRISCHEN_S = 120
|
||||
_NAME = re.compile(r"[\w.@+-]{1,80}")
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "konfig-host.json"
|
||||
|
||||
|
||||
def speichern(daten: object) -> dict:
|
||||
"""POST /api/homelab/ausfuehrer/konfig — schon geschwärzt; nur Text, nur sinnvolle Pfade."""
|
||||
d = daten if isinstance(daten, dict) else {}
|
||||
dateien = {str(k): str(v) for k, v in (d.get("dateien") or {}).items()
|
||||
if isinstance(k, str) and isinstance(v, str) and ".." not in k and not k.startswith("/")}
|
||||
stand = {"zeit": float(d.get("zeit") or time.time()), "dateien": dateien,
|
||||
"geschwaerzt": int(d.get("geschwaerzt") or 0), "fehler": [str(f)[:200] for f in d.get("fehler") or []]}
|
||||
pfad = _pfad()
|
||||
tmp = pfad.with_name(pfad.name + ".tmp")
|
||||
with _lock:
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(stand, f, ensure_ascii=False)
|
||||
os.replace(tmp, pfad)
|
||||
return {"ok": True, "dateien": len(dateien)}
|
||||
|
||||
|
||||
def _lesen() -> dict:
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {"zeit": None, "dateien": {}, "geschwaerzt": 0, "fehler": ["Der Ausführer hat noch keine Konfig geschickt."]}
|
||||
return daten if isinstance(daten, dict) else {"zeit": None, "dateien": {}, "geschwaerzt": 0, "fehler": []}
|
||||
|
||||
|
||||
def auffrischen() -> str | None:
|
||||
"""Den Ausführer jetzt sammeln lassen. None = geklappt, sonst der Grund."""
|
||||
auftrag = kanal.anlegen("konfig_senden")
|
||||
ergebnis = kanal.warten(auftrag, AUFFRISCHEN_S)
|
||||
if not ergebnis:
|
||||
return "Der Ausführer hat nicht rechtzeitig geantwortet; es gilt sein letzter Stand."
|
||||
if ergebnis.get("status") != "fertig":
|
||||
return f"Der Ausführer konnte nicht sammeln: {str(ergebnis.get('text') or '')[:200]}"
|
||||
return None
|
||||
|
||||
|
||||
def arcane_projekte() -> tuple[dict[str, str], int, list[str]]:
|
||||
"""Compose-Datei und .env jedes Arcane-Projekts, geschwärzt: ({Pfad: Text}, Stellen, Fehler)."""
|
||||
from services.homelab.einstellungen import arcane_url
|
||||
|
||||
url = arcane_url()
|
||||
if not url or not arcane.schluessel():
|
||||
return {}, 0, ["Arcane: ohne Adresse oder API-Schlüssel keine Projekte."]
|
||||
dateien: dict[str, str] = {}
|
||||
zahl, fehler = 0, []
|
||||
try:
|
||||
for umgebung in arcane.umgebungen(url):
|
||||
projekte = (arcane._get(url, f"/environments/{umgebung['id']}/projects?limit=200") or {}).get("data") or []
|
||||
for p in projekte:
|
||||
name = str(p.get("name") or "")
|
||||
if not _NAME.fullmatch(name) or p.get("id") is None:
|
||||
continue
|
||||
d = (arcane._get(url, f"/environments/{umgebung['id']}/projects/{p['id']}/compose") or {}).get("data") or {}
|
||||
datei = str(d.get("composeFileName") or "compose.yaml")
|
||||
for pfad, inhalt in ((f"arcane/{name}/{datei if _NAME.fullmatch(datei) else 'compose.yaml'}",
|
||||
d.get("composeContent")), (f"arcane/{name}/.env", d.get("envContent"))):
|
||||
if isinstance(inhalt, str) and inhalt.strip():
|
||||
text, n = schwaerzen(inhalt)
|
||||
dateien[pfad], zahl = text, zahl + n
|
||||
except (httpx.HTTPError, ValueError, TypeError, AttributeError) as exc:
|
||||
fehler.append(f"Arcane: {exc.__class__.__name__}")
|
||||
return dateien, zahl, fehler
|
||||
|
||||
|
||||
def lage(frisch: bool = False) -> dict:
|
||||
"""Für GET /api/homelab/konfig (die KI-Box): alle geschwärzten Dateien des Homelabs."""
|
||||
fehler: list[str] = []
|
||||
if frisch and (grund := auffrischen()):
|
||||
fehler.append(grund)
|
||||
host = _lesen()
|
||||
arcane_dateien, arcane_zahl, arcane_fehler = arcane_projekte()
|
||||
return {"zeit": host.get("zeit"), "dateien": {**(host.get("dateien") or {}), **arcane_dateien},
|
||||
"geschwaerzt": int(host.get("geschwaerzt") or 0) + arcane_zahl,
|
||||
"fehler": fehler + list(host.get("fehler") or []) + arcane_fehler}
|
||||
@@ -74,8 +74,11 @@ _AKTION = {
|
||||
"Änderungslisten"),
|
||||
# Seit 25.09.2026: Lucy liest das Protokoll eines Containers, wenn sie einen Hinweis untersucht.
|
||||
"journal": ("Protokoll gelesen (Lucy)", "Protokoll lesen gescheitert", "Protokoll wird gelesen", "Protokoll lesen"),
|
||||
# Seit 26.09.2026: Konfig-Sicherung — der Ausführer sammelt und schwärzt die Einstellungen.
|
||||
"konfig_senden": ("Konfig gesammelt", "Konfig sammeln gescheitert", "Konfig wird gesammelt", "Konfig sammeln"),
|
||||
}
|
||||
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog"}
|
||||
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog",
|
||||
"konfig_senden"}
|
||||
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
|
||||
"sicherheit": "Sicherheitsupdate",
|
||||
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Konfig-Sicherung ins eigene Gitea (Ausbauplan „Später“, Punkt 20, seit 26.09.2026).
|
||||
|
||||
User-Entscheid 25.09.2026: privates Repo, Geheimnisse geschwärzt. Täglich (mc2-konfig-sicherung.timer, 02:30) und per
|
||||
Knopf holt die KI-Box die geschwärzten Einstellungen des Homelabs (Proxmox, AdGuard, NPMplus, Arcane-Projekte —
|
||||
GET /api/homelab/konfig?frisch=1, services/homelab/konfig.py) und legt ihre eigenen dazu (llama-swap, Hermes-Config,
|
||||
SOUL.md, Hermes-Cron-Jobs; geschwärzt mit kern/schwaerzen.py). Alles wandert in das private Repo
|
||||
<Gitea>/<Benutzer>/homelab-konfig: Jede Änderung ist dort als Vorher-nachher sichtbar und lässt sich zurückholen.
|
||||
Committet wird nur, wenn sich etwas geändert hat.
|
||||
|
||||
Die Arbeitskopie (~/konfig-sicherung) ist Wegwerfware: Vor jedem Lauf wird sie auf den Stand in Gitea gesetzt, dann mit
|
||||
dem heutigen Stand überschrieben. Der Zugang ist der vorhandene der Box (~/.git-credentials, auch für Gitea-API und
|
||||
Anlegen des Repos beim ersten Lauf); er erscheint nie in einer Befehlszeile oder Ausgabe.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
import httpx
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.schwaerzen import schwaerzen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
REPO = "homelab-konfig"
|
||||
GITEA_URL = os.environ.get("MC_GITEA_URL", "http://192.168.178.153:3000").rstrip("/")
|
||||
GIT_ZUGANG = Path(os.environ.get("MC_GIT_ZUGANG", str(Path.home() / ".git-credentials")))
|
||||
ARBEIT = Path(os.environ.get("MC_KONFIG_ARBEIT", str(Path.home() / "konfig-sicherung")))
|
||||
AUTOR = ("Homelab Orchestrator", "orchestrator@homelab.local")
|
||||
BEREICHE = ("proxmox", "adguard", "npmplus", "arcane", "ki-box")
|
||||
_PFAD = re.compile(r"(?:proxmox|adguard|npmplus|arcane|ki-box)/[\w./@+-]{1,200}")
|
||||
BOX_DATEIEN = {
|
||||
"ki-box/llama-swap/config.yaml": Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml")),
|
||||
"ki-box/hermes/config.yaml": Path.home() / ".hermes" / "config.yaml",
|
||||
"ki-box/hermes/SOUL.md": Path.home() / ".hermes" / "SOUL.md",
|
||||
"ki-box/hermes/cron-jobs.json": Path.home() / ".hermes" / "cron" / "jobs.json",
|
||||
}
|
||||
README = """# Konfig-Sicherung des Homelab Orchestrators
|
||||
|
||||
Geschrieben vom Homelab Orchestrator (KI-Box), täglich um 02:30 und per Knopf unter Einstellungen → Homelab.
|
||||
Committet wird nur, wenn sich etwas geändert hat — die Geschichte dieses Repos ist die Geschichte der Einstellungen.
|
||||
|
||||
| Ordner | Woher |
|
||||
|---|---|
|
||||
| `proxmox/` | Proxmox-Host: Gäste (`lxc/`, `qemu-server/`), Speicher, Sicherungsaufträge, Netz |
|
||||
| `adguard/` | AdGuard Home (`AdGuardHome.yaml`) |
|
||||
| `npmplus/` | NPMplus: Proxy-Hosts, Weiterleitungen, Streams, Zugangslisten, eigene nginx-Schnipsel |
|
||||
| `arcane/` | Arcane: Compose-Datei und `.env` je Projekt |
|
||||
| `ki-box/` | KI-Box: llama-swap, Hermes-Config, `SOUL.md`, Hermes-Cron-Jobs |
|
||||
|
||||
**Geheimnisse sind geschwärzt** (`«geschwärzt»`): Passwörter und ihre Hashes, Tokens, API-Schlüssel, private Schlüssel.
|
||||
Wer eine Datei zurückholt, trägt sie selbst wieder ein.
|
||||
|
||||
Zurückholen: in Gitea die Datei öffnen → „Verlauf“ → den gewünschten Stand ansehen und den Inhalt übernehmen.
|
||||
"""
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _stand_pfad() -> Path:
|
||||
return einstellungen().daten_dir / "konfig-sicherung.json"
|
||||
|
||||
|
||||
def zugang() -> tuple[str, str] | None:
|
||||
"""(Benutzer, Token) für Gitea aus ~/.git-credentials. Erscheint nie in einer Ausgabe."""
|
||||
try:
|
||||
zeilen = GIT_ZUGANG.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return None
|
||||
ziel = urlsplit(GITEA_URL).hostname
|
||||
ersatz = None
|
||||
for zeile in zeilen:
|
||||
u = urlsplit(zeile.strip())
|
||||
if not (u.username and u.password and u.hostname):
|
||||
continue
|
||||
host = unquote(u.hostname).split(":", 1)[0]
|
||||
if host == ziel:
|
||||
return unquote(u.username), unquote(u.password)
|
||||
if "ddnsfree" in host and ersatz is None:
|
||||
ersatz = (unquote(u.username), unquote(u.password))
|
||||
return ersatz
|
||||
|
||||
|
||||
def repo_sicherstellen(benutzer: str, token: str) -> str:
|
||||
"""Das private Repo — beim ersten Lauf angelegt. Rückgabe: die Klon-Adresse (ohne Zugangsdaten)."""
|
||||
kopf = {"Authorization": f"token {token}"}
|
||||
r = httpx.get(f"{GITEA_URL}/api/v1/repos/{benutzer}/{REPO}", headers=kopf, timeout=15)
|
||||
if r.status_code == 404:
|
||||
neu = httpx.post(f"{GITEA_URL}/api/v1/user/repos", headers=kopf, timeout=15, json={
|
||||
"name": REPO, "private": True, "auto_init": False, "default_branch": "main",
|
||||
"description": "Konfig-Sicherung des Homelab Orchestrators (Geheimnisse geschwärzt)"})
|
||||
neu.raise_for_status()
|
||||
log.info("konfig-sicherung: Repo %s/%s angelegt (privat)", benutzer, REPO)
|
||||
else:
|
||||
r.raise_for_status()
|
||||
return f"{GITEA_URL}/{benutzer}/{REPO}.git"
|
||||
|
||||
|
||||
def _git(*args: str, zeitlimit: int = 90) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", "-C", str(ARBEIT), *args], capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", timeout=zeitlimit, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"})
|
||||
|
||||
|
||||
def box_dateien() -> dict[str, str]:
|
||||
dateien = {}
|
||||
for ziel, quelle in BOX_DATEIEN.items():
|
||||
try:
|
||||
dateien[ziel] = quelle.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
return dateien
|
||||
|
||||
|
||||
def _homelab(frisch: bool) -> dict:
|
||||
"""Direkt beim Homelab-Teil (nicht über die Durchreiche: frisch=1 dauert bis zu zwei Minuten, die Durchreiche
|
||||
wartet nur 30 Sekunden)."""
|
||||
basis = einstellungen().partner_url
|
||||
if not basis:
|
||||
return {"dateien": {}, "geschwaerzt": 0, "fehler": ["Es ist kein Homelab-Teil eingerichtet."]}
|
||||
try:
|
||||
r = httpx.get(f"{basis}/api/homelab/konfig", params={"frisch": int(frisch)}, timeout=200)
|
||||
r.raise_for_status()
|
||||
daten = r.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
return {"dateien": {}, "geschwaerzt": 0, "fehler": [f"Homelab nicht erreichbar ({exc.__class__.__name__})."]}
|
||||
return daten if isinstance(daten, dict) else {"dateien": {}, "geschwaerzt": 0, "fehler": []}
|
||||
|
||||
|
||||
def _schreiben(dateien: dict[str, str]) -> None:
|
||||
"""Die Arbeitskopie mit dem heutigen Stand überschreiben (was es nicht mehr gibt, fällt weg)."""
|
||||
for bereich in BEREICHE:
|
||||
shutil.rmtree(ARBEIT / bereich, ignore_errors=True)
|
||||
for pfad, text in dateien.items():
|
||||
ziel = ARBEIT / pfad
|
||||
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||||
ziel.write_text(text, encoding="utf-8", newline="\n")
|
||||
(ARBEIT / "README.md").write_text(README, encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def _vorbereiten(url: str) -> None:
|
||||
if not (ARBEIT / ".git").exists():
|
||||
ARBEIT.mkdir(parents=True, exist_ok=True)
|
||||
_git("init", "-q", "-b", "main")
|
||||
_git("remote", "add", "origin", url)
|
||||
else:
|
||||
_git("remote", "set-url", "origin", url)
|
||||
_git("fetch", "-q", "origin")
|
||||
if _git("rev-parse", "--verify", "-q", "origin/main").returncode == 0:
|
||||
_git("reset", "-q", "--hard", "origin/main")
|
||||
|
||||
|
||||
def sichern(frisch: bool = True, jetzt: float | None = None) -> dict:
|
||||
"""Ein Lauf: sammeln, schreiben, bei Änderungen committen und pushen. Rückgabe ok + text oder detail."""
|
||||
if not _lock.acquire(blocking=False):
|
||||
return {"ok": False, "detail": "Die Konfig-Sicherung läuft gerade schon."}
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
try:
|
||||
homelab = _homelab(frisch)
|
||||
dateien: dict[str, str] = {p: t for p, t in (homelab.get("dateien") or {}).items()
|
||||
if isinstance(p, str) and isinstance(t, str) and _PFAD.fullmatch(p) and ".." not in p}
|
||||
geschwaerzt = int(homelab.get("geschwaerzt") or 0)
|
||||
for pfad, text in box_dateien().items():
|
||||
dateien[pfad], n = schwaerzen(text)
|
||||
geschwaerzt += n
|
||||
fehler = [str(f) for f in homelab.get("fehler") or []]
|
||||
if not dateien:
|
||||
return _ende(jetzt, "fehler", "Keine Einstellungen bekommen." + (f" {'; '.join(fehler)}" if fehler else ""))
|
||||
z = zugang()
|
||||
if not z:
|
||||
return _ende(jetzt, "fehler", "Kein Gitea-Zugang in ~/.git-credentials.")
|
||||
try:
|
||||
url = repo_sicherstellen(*z)
|
||||
except httpx.HTTPError as exc:
|
||||
return _ende(jetzt, "fehler", f"Gitea antwortet nicht wie erwartet ({exc.__class__.__name__}).")
|
||||
for versuch in range(2):
|
||||
_vorbereiten(url)
|
||||
_schreiben(dateien)
|
||||
_git("add", "-A")
|
||||
aenderungen = [zeile.split("\t", 1) for zeile in _git("diff", "--cached", "--name-status").stdout.splitlines()
|
||||
if "\t" in zeile]
|
||||
if not aenderungen:
|
||||
return _ende(jetzt, "unveraendert", f"Keine Änderung ({len(dateien)} Dateien).", dateien=len(dateien),
|
||||
geschwaerzt=geschwaerzt, fehler=fehler)
|
||||
zaehlung = {"A": 0, "M": 0, "D": 0}
|
||||
for art, _ in aenderungen:
|
||||
zaehlung[art[:1]] = zaehlung.get(art[:1], 0) + 1
|
||||
wann = datetime.fromtimestamp(jetzt, LOCAL_TZ).strftime("%d.%m.%Y %H:%M")
|
||||
nachricht = (f"Konfig-Sicherung {wann}: {zaehlung['M']} geändert, {zaehlung['A']} neu, {zaehlung['D']} weg\n\n"
|
||||
+ "\n".join(f"{art} {pfad}" for art, pfad in aenderungen[:80]))
|
||||
_git("-c", f"user.name={AUTOR[0]}", "-c", f"user.email={AUTOR[1]}", "commit", "-q", "-m", nachricht)
|
||||
gesendet = _git("push", "-q", "origin", "HEAD:main", zeitlimit=120)
|
||||
if gesendet.returncode == 0:
|
||||
commit = _git("rev-parse", "--short", "HEAD").stdout.strip()
|
||||
return _ende(jetzt, "gesichert", f"{len(aenderungen)} Änderungen gesichert ({zaehlung['M']} geändert, "
|
||||
f"{zaehlung['A']} neu, {zaehlung['D']} weg).", commit=commit, dateien=len(dateien),
|
||||
geschwaerzt=geschwaerzt, fehler=fehler, aenderungen=[p for _, p in aenderungen[:40]])
|
||||
log.warning("konfig-sicherung: push abgelehnt (Versuch %d): %s", versuch + 1, gesendet.stderr.strip()[:200])
|
||||
return _ende(jetzt, "fehler", "Gitea nimmt den Stand nicht an (push abgelehnt).")
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return _ende(jetzt, "fehler", f"Git ging nicht ({exc.__class__.__name__}).")
|
||||
finally:
|
||||
_lock.release()
|
||||
|
||||
|
||||
def _ende(jetzt: float, ergebnis: str, text: str, **mehr: object) -> dict:
|
||||
z = zugang()
|
||||
stand = {"zeit": jetzt, "ergebnis": ergebnis, "text": text,
|
||||
"repo": f"{GITEA_URL}/{z[0]}/{REPO}" if z else None, **mehr}
|
||||
try:
|
||||
pfad = _stand_pfad()
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(stand, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
except OSError:
|
||||
log.warning("konfig-sicherung: Stand nicht schreibbar", exc_info=True)
|
||||
log.info("konfig-sicherung: %s — %s", ergebnis, text)
|
||||
if ergebnis == "fehler":
|
||||
return {"ok": False, "detail": text}
|
||||
return {"ok": True, "text": text}
|
||||
|
||||
|
||||
def im_hintergrund() -> dict:
|
||||
"""Knopf „Jetzt sichern“: im Hintergrund, die Oberfläche fragt danach stand()."""
|
||||
if _lock.locked():
|
||||
return {"ok": False, "detail": "Die Konfig-Sicherung läuft gerade schon."}
|
||||
threading.Thread(target=sichern, name="konfig-sicherung", daemon=True).start()
|
||||
return {"ok": True, "text": "Die Sicherung läuft (bis zu zwei Minuten); das Ergebnis steht gleich hier."}
|
||||
|
||||
|
||||
def stand() -> dict:
|
||||
"""Für GET /api/konfig-sicherung: der letzte Lauf und wo das Repo liegt."""
|
||||
try:
|
||||
zuletzt = json.loads(_stand_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
zuletzt = None
|
||||
z = zugang()
|
||||
return {"zuletzt": zuletzt if isinstance(zuletzt, dict) else None,
|
||||
"repo": f"{GITEA_URL}/{z[0]}/{REPO}" if z else None}
|
||||
@@ -117,6 +117,8 @@ TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
|
||||
"pbs-backup": ("Sicherung auf den PBS", False),
|
||||
# Seit 25.09.2026 (Ausbauplan Welle 3): sonntags 07:30 der Wochenbericht in Lucys Stimme.
|
||||
"mc2-wochenbericht": ("Wochenbericht", False),
|
||||
# Seit 26.09.2026: täglich 02:30 die Konfig-Sicherung ins private Gitea-Repo (Ausbauplan „Später“, Punkt 20).
|
||||
"mc2-konfig-sicherung": ("Konfig-Sicherung", False),
|
||||
}
|
||||
|
||||
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
|
||||
|
||||
Reference in New Issue
Block a user