Feat: Voll-Zustands-Backup + getesteter Restore + taeglicher Timer

Behebt 3 von 4 Backup-Luecken (Schicht 1, lokal):
- backup.sh sichert jetzt den ECHTEN Zustand als ein Tarball mc2-state-<ts>.tar.gz:
  mem0 (Chroma+history.db), ~/.hermes (config.yaml, .env, plugins/), llama-swap config.
  Vorher wurde nur die alte/leere mc2-memory.db gesichert. chmod 600 (enthaelt .env).
- restore.sh: --list / --dry-run / [--yes] <datei|latest>; macht VOR dem Zurueckspielen
  ein Sicherheits-Backup, stoppt/startet Dienste, Health-Check. Live round-trip verifiziert.
- mc2-backup.timer/.service: taegliches Backup ~03:30 (vorher gab es KEINE Automatik).
- backend/services/backup.py delegiert an backup.sh (eine Quelle der Wahrheit); UI-Button
  + /api/system/backups zeigen die Tarballs.
- docs/BACKUP.md: Backup/Restore-Anleitung.

Offen (Schicht 2): Off-Box-Spiegel (Box ist Bare Metal -> PBS-Client oder rsync in LXC).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 21:25:39 +02:00
parent 9fb321e45e
commit 8554e7b29c
13 changed files with 251 additions and 48 deletions
+53 -31
View File
@@ -1,51 +1,73 @@
"""
Backup der „Verfassung" (Shared-Memory-SQLite) + aller Configs.
Snapshot in einen Zeitstempel-Ordner; die letzten N bleiben erhalten.
Voll-Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config).
Delegiert an deploy/backup.sh (eine Quelle der Wahrheit, identisch zum systemd-Timer);
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BACKUP.md.
"""
import shutil
import time
import subprocess
import tarfile
from pathlib import Path
from config import CONFIG_PATH, MEMORY_DB, MODELS_DIR
from config import MODELS_DIR
BACKUP_DIR = Path(MODELS_DIR) / "mc2-backups"
RETAIN = 7
SRC_ROOT = Path(__file__).resolve().parents[2]
BACKUP_SH = SRC_ROOT / "deploy" / "backup.sh"
def _safe_copy(src: Path, dst_dir: Path) -> str | None:
try:
if src and src.exists():
shutil.copy2(src, dst_dir / src.name)
return src.name
except Exception:
pass
def _ts(p: Path) -> str:
"""Zeitstempel aus 'mc2-state-<ts>.tar.gz' (Path.stem ließe '.tar' stehen)."""
return p.name[len("mc2-state-"):-len(".tar.gz")]
def _latest() -> Path | None:
if not BACKUP_DIR.exists():
return None
snaps = sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True)
return snaps[0] if snaps else None
def _components(tarball: Path) -> list[str]:
"""Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
try:
with tarfile.open(tarball, "r:gz") as t:
top = {m.name.split("/")[1] for m in t.getmembers()
if m.name.startswith("./") and "/" in m.name[2:]}
top |= {m.name[2:] for m in t.getmembers()
if m.name.startswith("./") and "/" not in m.name[2:] and m.isfile()}
return sorted(x for x in top if x)
except Exception:
return []
def backup_now() -> dict:
"""Erstellt einen Snapshot (Memory-DB inkl. WAL/SHM + Configs). Alte Snapshots
über RETAIN hinaus werden entfernt."""
ts = time.strftime("%Y%m%d-%H%M%S")
dst = BACKUP_DIR / ts
dst.mkdir(parents=True, exist_ok=True)
saved = []
for src in (MEMORY_DB, Path(str(MEMORY_DB) + "-wal"), Path(str(MEMORY_DB) + "-shm"),
CONFIG_PATH):
if (name := _safe_copy(src, dst)):
saved.append(name)
# Aufräumen: nur die letzten RETAIN Snapshots behalten.
snaps = sorted([p for p in BACKUP_DIR.iterdir() if p.is_dir()], reverse=True)
for old in snaps[RETAIN:]:
shutil.rmtree(old, ignore_errors=True)
return {"ok": bool(saved), "snapshot": ts, "files": saved, "dir": str(dst)}
"""Erstellt einen Voll-Zustands-Snapshot via deploy/backup.sh."""
try:
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
if r.returncode != 0:
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
except Exception as exc: # noqa: BLE001
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
latest = _latest()
if not latest:
return {"ok": False, "snapshot": "", "files": [], "error": "Kein Backup erzeugt"}
return {
"ok": True,
"snapshot": _ts(latest),
"files": _components(latest),
"size_mb": round(latest.stat().st_size / 1_000_000, 2),
}
def list_backups() -> list[dict]:
if not BACKUP_DIR.exists():
return []
out = []
for p in sorted([d for d in BACKUP_DIR.iterdir() if d.is_dir()], reverse=True):
files = [f.name for f in p.iterdir() if f.is_file()]
out.append({"snapshot": p.name, "files": files})
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
out.append({
"snapshot": _ts(p),
"file": p.name,
"size_mb": round(p.stat().st_size / 1_000_000, 2),
})
return out
+40 -11
View File
@@ -1,18 +1,47 @@
#!/usr/bin/env bash
# Backup der Shared-Memory-SQLite + Configs (cron-bar auf der Box).
# Behält die letzten 7 Snapshots. Identisch zur /api/system/backup-Logik.
# Voll-Zustands-Backup der AI-Box: alles, was NICHT aus Git oder per Re-Download
# zurückkommt. Erzeugt EIN Tarball mc2-state-<ts>.tar.gz, behält die letzten N.
# Läuft per systemd-Timer (täglich), manuell, oder über den UI-Snapshot-Button.
#
# Inhalt: mem0 (Chroma + history.db) · ~/.hermes (config.yaml, .env, plugins/) ·
# /etc/llama-swap/config.yaml
# NICHT enthalten (bewusst): GGUF-Modelle (riesig, neu ladbar), MC2-Code (Git), venvs.
#
# ACHTUNG: das Tarball enthält ~/.hermes/.env (Secrets) → chmod 600, nicht in Git.
set -euo pipefail
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
MEM_DB="${MC_MEMORY_DB:-$MODELS_DIR/mc2-memory.db}"
DEST_DIR="$MODELS_DIR/mc2-backups"
RETAIN="${MC_BACKUP_RETAIN:-14}"
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
DEST="$MODELS_DIR/mc2-backups/$(date +%Y%m%d-%H%M%S)"
HERMES="${HERMES_HOME:-$HOME/.hermes}"
TS="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$DEST"
for f in "$MEM_DB" "$MEM_DB-wal" "$MEM_DB-shm" "$LSWAP"; do
[ -f "$f" ] && cp -p "$f" "$DEST/" || true
done
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/mem0" "$STAGE/hermes" "$STAGE/llama-swap"
# nur die letzten 7 behalten
ls -1dt "$MODELS_DIR"/mc2-backups/*/ 2>/dev/null | tail -n +8 | xargs -r rm -rf
echo "OK — Backup unter $DEST"
[ -d "$MEM0_DIR" ] && cp -a "$MEM0_DIR/." "$STAGE/mem0/" || true
[ -f "$HERMES/config.yaml" ] && cp -a "$HERMES/config.yaml" "$STAGE/hermes/" || true
[ -f "$HERMES/.env" ] && cp -a "$HERMES/.env" "$STAGE/hermes/" || true
[ -d "$HERMES/plugins" ] && cp -a "$HERMES/plugins" "$STAGE/hermes/plugins" || true
[ -f "$LSWAP" ] && cp -a "$LSWAP" "$STAGE/llama-swap/" || true
cat > "$STAGE/MANIFEST.txt" <<EOF
mc2-state backup
created : $TS
host : $(hostname)
inhalt : mem0 (chroma + history.db), hermes (config.yaml, .env, plugins/), llama-swap (config.yaml)
restore : bash ~/mission-control-v2/deploy/restore.sh mc2-state-$TS.tar.gz
EOF
mkdir -p "$DEST_DIR"
OUT="$DEST_DIR/mc2-state-$TS.tar.gz"
tar -czf "$OUT" -C "$STAGE" .
chmod 600 "$OUT" # enthält .env (Secrets)
# Retention: nur die letzten N behalten.
ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | tail -n +$((RETAIN + 1)) | xargs -r rm -f
echo "OK — Backup: $OUT ($(du -h "$OUT" | cut -f1))"
+4
View File
@@ -48,10 +48,14 @@ cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-c
cp "$SRC/deploy/hermes-terminal.service" "$HOME/.config/systemd/user/hermes-terminal.service"
# Mem0-Sidecar-Unit (nur wenn das venv existiert).
[ -x "$HOME/.mem0/venv/bin/python" ] && cp "$SRC/deploy/mem0-service.service" "$HOME/.config/systemd/user/mem0-service.service"
# Tägliches Zustands-Backup (mem0 + Configs/Secrets) — Timer + oneshot-Service. Siehe docs/BACKUP.md.
cp "$SRC/deploy/mc2-backup.service" "$HOME/.config/systemd/user/mc2-backup.service"
cp "$SRC/deploy/mc2-backup.timer" "$HOME/.config/systemd/user/mc2-backup.timer"
systemctl --user daemon-reload
systemctl --user enable mission-control-2 >/dev/null 2>&1 || true
systemctl --user enable hermes-terminal >/dev/null 2>&1 || true
systemctl --user enable mem0-service >/dev/null 2>&1 || true
systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
loginctl enable-linger "$USER" >/dev/null 2>&1 || true
# Mem0-Sidecar VOR dem Backend (re)starten, damit /api/memory sofort bedient wird.
[ -x "$HOME/.mem0/venv/bin/python" ] && systemctl --user restart mem0-service 2>/dev/null || true
+7
View File
@@ -0,0 +1,7 @@
[Unit]
Description=MC2 Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config)
Documentation=file:%h/mission-control-v2/docs/BACKUP.md
[Service]
Type=oneshot
ExecStart=/bin/bash %h/mission-control-v2/deploy/backup.sh
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Tägliches MC2 Zustands-Backup
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Wiederherstellung eines mc2-state-Backups (siehe backup.sh).
# Stoppt die Dienste, spielt den Zustand zurück, startet neu — und macht VORHER
# automatisch ein Sicherheits-Backup des aktuellen Zustands (Restore ist reversibel).
#
# Nutzung:
# bash restore.sh --list # vorhandene Backups zeigen
# bash restore.sh --dry-run <datei|latest> # nur anzeigen, was passieren würde
# bash restore.sh [--yes] <datei|latest> # wiederherstellen (--yes = ohne Rückfrage)
set -euo pipefail
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
DEST_DIR="$MODELS_DIR/mc2-backups"
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
HERMES="${HERMES_HOME:-$HOME/.hermes}"
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
SERVICES="mem0-service mission-control-2 hermes-gateway"
usage() { echo "Nutzung: restore.sh --list | restore.sh [--dry-run] [--yes] <datei|latest>"; }
list_backups() {
echo "Verfügbare Backups in $DEST_DIR:"
ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | while read -r f; do
printf " %s (%s)\n" "$(basename "$f")" "$(du -h "$f" | cut -f1)"
done
[ -n "$(ls -A "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null || true)" ] || echo " (keine)"
}
[ $# -eq 0 ] && { usage; exit 1; }
DRY=0; YES=0; TARGET=""
for a in "$@"; do
case "$a" in
--list) list_backups; exit 0 ;;
--dry-run) DRY=1 ;;
--yes) YES=1 ;;
-*) usage; exit 1 ;;
*) TARGET="$a" ;;
esac
done
[ -z "$TARGET" ] && { usage; exit 1; }
[ "$TARGET" = "latest" ] && TARGET="$(ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | head -1 || true)"
[ -f "$TARGET" ] || TARGET="$DEST_DIR/$(basename "${TARGET:-}")"
[ -f "$TARGET" ] || { echo "Backup nicht gefunden: ${TARGET:-<leer>}"; echo; list_backups; exit 1; }
echo "Restore-Quelle: $TARGET"
STAGE="$(mktemp -d)"; trap 'rm -rf "$STAGE"' EXIT
tar -xzf "$TARGET" -C "$STAGE"
echo "--- Inhalt ---"; cat "$STAGE/MANIFEST.txt" 2>/dev/null || true; echo "--------------"
if [ "$DRY" = 1 ]; then
echo "[dry-run] würde zurücksetzen:"
[ -d "$STAGE/mem0" ] && echo " • mem0 → $MEM0_DIR (wird ersetzt)"
[ -f "$STAGE/hermes/config.yaml" ] && echo "$HERMES/config.yaml"
[ -f "$STAGE/hermes/.env" ] && echo "$HERMES/.env"
[ -d "$STAGE/hermes/plugins" ] && echo "$HERMES/plugins/"
[ -f "$STAGE/llama-swap/config.yaml" ] && echo "$LSWAP"
echo " • Dienste neu starten: $SERVICES"
exit 0
fi
if [ "$YES" != 1 ]; then
echo "WARNUNG: Das überschreibt den AKTUELLEN Zustand und startet die Dienste neu."
read -r -p "Fortfahren? (tippe 'ja'): " ans
[ "$ans" = "ja" ] || { echo "Abgebrochen."; exit 1; }
fi
echo "→ Sicherheits-Backup des aktuellen Zustands…"
bash "$SRC/deploy/backup.sh" || echo " (Pre-Restore-Backup fehlgeschlagen — fahre fort)"
echo "→ Dienste stoppen…"
systemctl --user stop $SERVICES 2>/dev/null || true
if [ -d "$STAGE/mem0" ]; then rm -rf "$MEM0_DIR"; mkdir -p "$MEM0_DIR"; cp -a "$STAGE/mem0/." "$MEM0_DIR/"; fi
[ -f "$STAGE/hermes/config.yaml" ] && { mkdir -p "$HERMES"; cp -a "$STAGE/hermes/config.yaml" "$HERMES/"; }
[ -f "$STAGE/hermes/.env" ] && cp -a "$STAGE/hermes/.env" "$HERMES/"
[ -d "$STAGE/hermes/plugins" ] && { mkdir -p "$HERMES/plugins"; cp -a "$STAGE/hermes/plugins/." "$HERMES/plugins/"; }
[ -f "$STAGE/llama-swap/config.yaml" ] && cp -a "$STAGE/llama-swap/config.yaml" "$LSWAP" 2>/dev/null || true
echo "→ Dienste starten…"
systemctl --user start $SERVICES 2>/dev/null || true
sleep 4
printf "→ Health: "; curl -sf http://127.0.0.1:9001/api/health && echo || echo "(Backend nicht erreichbar — Logs prüfen)"
echo "OK — Restore abgeschlossen."
+47
View File
@@ -0,0 +1,47 @@
# Backup & Restore
Sichert den **nicht wiederherstellbaren Zustand** der AI-Box. Code kommt aus Git,
Modelle sind neu ladbar — gesichert wird nur, was sonst weg wäre.
## Was im Backup ist
- **Gedächtnis:** `/srv/models/mem0/` (Chroma-Vektoren + `history.db`)
- **Hermes:** `~/.hermes/config.yaml`, `~/.hermes/.env` (**Secrets!**), `~/.hermes/plugins/`
- **Engine:** `/etc/llama-swap/config.yaml`
Nicht enthalten (bewusst): GGUF-Modelle, MC2-Code (Git), venvs, systemd-Units (aus `deploy.sh`).
Jedes Backup ist ein Tarball `mc2-state-<zeitstempel>.tar.gz` unter `/srv/models/mc2-backups/`,
`chmod 600` (enthält `.env`). Es werden die letzten **14** behalten.
> ⚠️ Das Tarball enthält Secrets und liegt aktuell auf **derselben Platte** wie die Daten.
> Für echten Desaster-Schutz noch eine Off-Box-Kopie einrichten (PBS / rsync). Siehe „Offen".
## Backup erstellen
- **Automatisch:** systemd-Timer `mc2-backup.timer`, täglich ~03:30. Status:
`systemctl --user list-timers mc2-backup.timer`
- **Manuell (Box):** `bash ~/mission-control-v2/deploy/backup.sh`
- **UI:** Wartungs-Drawer → „Snapshot erstellen"
## Wiederherstellen (Restore)
Restore läuft **nur per CLI auf der Box** (bewusst — er stoppt Dienste und überschreibt Configs).
Vor dem Zurückspielen macht das Skript automatisch ein Sicherheits-Backup des aktuellen Zustands.
```bash
cd ~/mission-control-v2
bash deploy/restore.sh --list # vorhandene Backups anzeigen
bash deploy/restore.sh --dry-run latest # zeigen, was passieren würde
bash deploy/restore.sh latest # neuestes wiederherstellen (mit Rückfrage)
bash deploy/restore.sh mc2-state-YYYYMMDD-HHMMSS.tar.gz # bestimmtes Backup
```
Ablauf: Sicherheits-Backup → Dienste stoppen (`mem0-service`, `mission-control-2`,
`hermes-gateway`) → Dateien zurückspielen (mem0 wird **ersetzt**, Configs überschrieben) →
Dienste starten → Health-Check.
Ein Backup von einer **anderen** Maschine zurückspielen: Tarball nach
`/srv/models/mc2-backups/` kopieren, dann `restore.sh <datei>`.
## Offen / TODO
- **Off-Box-Spiegel** (Schicht 2): Box ist Bare Metal (kein Proxmox-Gast → kein vzdump).
Saubere Wege: Proxmox Backup Server + `proxmox-backup-client` ODER rsync in einen
vzdump-gesicherten LXC. Noch nicht eingerichtet.
@@ -1,4 +1,4 @@
import{r as K,a as Hv,g as Hr,R as Um,c as q2,j as Ne,l as Z2,S as K2,b as Vx,T as $2}from"./index-BbiMdr0-.js";/**
import{r as K,a as Hv,g as Hr,R as Um,c as q2,j as Ne,l as Z2,S as K2,b as Vx,T as $2}from"./index-DkZXHl-q.js";/**
* @license
* Copyright 2010-2023 Three.js Authors
* SPDX-License-Identifier: MIT
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BbiMdr0-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-De4PPCGb.css">
<script type="module" crossorigin src="/assets/index-DkZXHl-q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-qbOD334z.css">
</head>
<body>
<div id="root"></div>
+2 -1
View File
@@ -403,7 +403,8 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">System-Backup &amp; Snapshot</h3>
<div className="p-4 rounded-xl border border-border/60 bg-background/20 space-y-3">
<p className="text-[10px] text-muted-foreground leading-normal">
Git-Snapshot der aktuellen Konfigurationen und des System-Zustands erzeugen.
Voll-Backup: Gedächtnis (Mem0), Hermes-Config &amp; Secrets, llama-swap-Config.
Läuft auch täglich automatisch. Wiederherstellen per <code>deploy/restore.sh</code> (siehe docs/BACKUP.md).
</p>
<button
onClick={doBackup}