diff --git a/Erklärung.txt b/Erklärung.txt index e33e692..24c13d0 100644 --- a/Erklärung.txt +++ b/Erklärung.txt @@ -261,24 +261,43 @@ beweist deshalb nicht automatisch, dass unter /data keine alten Experiment- oder Modelldateien mehr liegen. -AKTUELLER UMFANG DES BACKUP-CONTAINERS -====================================== +BACKUP UND DISASTER RECOVERY +============================ -Der Container mike-ai-backup sichert derzeit im Fünf-Stunden-Takt: +Athena verwendet zwei Sicherungsebenen: -- /etc/mike-ai -- /opt/mike-ai/stack -- mike-ai_router-state -- mike-ai_router-images -- portainer_data +1. mike-ai-backup schreibt alle fünf Stunden ein lokales Schnellbackup nach + /data/docker-backups. Darin liegen /etc/mike-ai, ganz /opt/mike-ai sowie + Router- und Portainer-Zustand. Dieses Backup deckt den Ausfall der + Systemplatte ab, solange /data erhalten bleibt. -Das Volume mike-ai_whisper-data wird nicht archiviert. Das Whisper-Modell wird -bei einem leeren Volume automatisch erneut heruntergeladen. Die großen -Hostverzeichnisse unter /data/models, /data/voice, /data/music und -/data/audio sind ebenfalls nicht automatisch Bestandteil dieses -Docker-Volume-Backups. Besonders eigene Trainingsdaten und trainierte -Stimmenmodelle benötigen deshalb eine zusätzliche Datensicherung, wenn sie -nicht reproduzierbar sind. +2. athena-disaster-backup schreibt nachts ein verschlüsseltes und + dedupliziertes Restic-Backup auf einen physisch anderen Speicher. Es enthält + zusätzlich eigene Stimmen, Trainingsdatensätze, Musik, Audioergebnisse, + Dashboard- und Projektdaten. Dieses Backup deckt den Ausfall der Datenplatte + und den gleichzeitigen Ausfall beider Platten ab. + +Die reproduzierbaren Modellgewichte unter /data/models werden nicht extern +doppelt gespeichert. Bei Verlust der Datenplatte werden sie aus den +versionierten Quellen neu geladen. Das Whisper-Volume wird ebenfalls neu +erzeugt. + +Nach Debian-Installation und dem Einhängen einer eigenen /data-Partition führt +disaster-recovery.sh den passenden Wiederaufbau aus: + +- --scenario system: Systemplatte neu, alte Datenplatte vorhanden +- --scenario data: Datenplatte neu, Systemplatte vorhanden +- --scenario all: beide Platten neu + +Das Skript formatiert keine Platten, führt keinen Neustart aus und beendet den +Wiederaufbau im sicheren LLM-Standardmodus. Details stehen in docs/RECOVERY.md. + +Zusätzlich entstehen alle fünf Stunden unter /data/emergency-backups bis zu +fünf verschlüsselte Notfallpakete. Sie können mit Prüfsumme direkt aus dem +Athena-Dashboard heruntergeladen werden. Ein auf einen anderen Rechner +heruntergeladenes Paket kann statt des externen Restic-Speichers als Quelle +für den Daten- oder Totalausfall dienen. Auf /data verbliebene Pakete schützen +nicht gegen den Ausfall genau dieser Datenplatte. ENTFERNTE KOMPONENTEN diff --git a/README.md b/README.md index a186047..a5768ff 100644 --- a/README.md +++ b/README.md @@ -173,16 +173,18 @@ Unraid-DockerMan-Templates. Details stehen in ## Wiederherstellung -Nach einer frischen Debian-Installation und erneut eingehängtem `/data`: +Nach einer frischen Debian-Installation und separat eingehängtem `/data` +übernimmt ein Orchestrator den vollständigen Wiederaufbau. Beispiel bei +erhaltener Datenplatte: ```bash -sudo ./install.sh --config /root/mike-ai-install.env -sudo ./restore.sh --check /data/docker-backups/athena-latest.tar.gz -sudo ./restore.sh /data/docker-backups/athena-latest.tar.gz -sudo ./smoke-test.sh +sudo ./disaster-recovery.sh --scenario system \ + --archive /data/docker-backups/athena-latest.tar.gz ``` -Der genaue Sicherungsumfang steht in [docs/RECOVERY.md](docs/RECOVERY.md). +Für den Ausfall der Datenplatte oder beider Platten wird das verschlüsselte +externe Restic-Backup verwendet. Der genaue Sicherungsumfang und alle drei +Szenarien stehen in [docs/RECOVERY.md](docs/RECOVERY.md). ## Verbindliche Dokumentation diff --git a/compose.yaml b/compose.yaml index b4f5684..a6d168f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -750,6 +750,7 @@ services: HOST_PROC: /host/proc HOST_DATA: /host/data HOST_MODELS: /host/models + DASHBOARD_BACKUP_DIR: /host/data/emergency-backups DASHBOARD_HISTORY_DB: /var/lib/llama-dashboard/history.sqlite3 DASHBOARD_HISTORY_INTERVAL: "15" DASHBOARD_DETAIL_RETENTION_DAYS: "21" @@ -797,7 +798,9 @@ services: - /var/run/docker.sock:/var/run/docker.sock:ro - /data/docker-backups:/archive - /etc/mike-ai:/backup/etc-mike-ai:ro - - /opt/mike-ai/stack:/backup/stack:ro + # Include every deployed specialist UI/worker source tree, not just the + # core checkout. Images themselves remain reproducible and are rebuilt. + - /opt/mike-ai:/backup/opt-mike-ai:ro - router-state:/backup/volumes/router-state:ro - router-images:/backup/volumes/router-images:ro - portainer-data:/backup/volumes/portainer-data:ro diff --git a/config/disaster-backup.env.example b/config/disaster-backup.env.example new file mode 100644 index 0000000..1220241 --- /dev/null +++ b/config/disaster-backup.env.example @@ -0,0 +1,18 @@ +# Root-only configuration for the encrypted off-host Restic repository. +# Copy to /etc/mike-ai/disaster-backup.env and chmod 600. +# +# Recommended: mount an Unraid backup share at /mnt/athena-offsite and use: +RESTIC_REPOSITORY=/mnt/athena-offsite/restic +RESTIC_REQUIRE_MOUNT=/mnt/athena-offsite + +# The password file must ALSO exist outside Athena (password manager/offline +# recovery USB). Without it a total-loss backup cannot be decrypted. +RESTIC_PASSWORD_FILE=/root/athena-restic-password + +RESTIC_TAG=athena-disaster +RESTIC_KEEP_DAILY=14 +RESTIC_KEEP_WEEKLY=8 +RESTIC_KEEP_MONTHLY=12 + +# Set true only after the repository and credentials have been tested. +DISASTER_BACKUP_ENABLED=false diff --git a/dev/test_dashboard_modes.py b/dev/test_dashboard_modes.py index a0cf21f..08e92bd 100644 --- a/dev/test_dashboard_modes.py +++ b/dev/test_dashboard_modes.py @@ -30,6 +30,7 @@ class DashboardModeTests(unittest.TestCase): cls.tempdir = tempfile.TemporaryDirectory() with patch.dict(os.environ, { "DASHBOARD_HISTORY_DB": str(Path(cls.tempdir.name) / "history.sqlite3"), + "DASHBOARD_BACKUP_DIR": str(Path(cls.tempdir.name) / "backups"), "ROUTER_URL": "http://router.test:8081", "ROUTER_API_KEY": "test-key", }): @@ -37,6 +38,8 @@ class DashboardModeTests(unittest.TestCase): cls.dashboard = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(cls.dashboard) + cls.backup_dir = Path(cls.tempdir.name) / "backups" + cls.backup_dir.mkdir() @classmethod def tearDownClass(cls): @@ -101,6 +104,20 @@ class DashboardModeTests(unittest.TestCase): self.assertIn("http://192.168.1.212:8011/", html) self.assertIn("http://192.168.1.212:8012/", html) + def test_dashboard_lists_only_portable_encrypted_backups(self): + valid = self.backup_dir / "athena-portable-2026-09-10T10-00-00Z.tar.zst.age" + valid.write_bytes(b"encrypted") + valid.with_name(valid.name + ".sha256").write_text( + "a" * 64 + " " + valid.name + "\n", encoding="utf-8" + ) + (self.backup_dir / "unrelated.txt").write_text("ignore", encoding="utf-8") + + backups = self.dashboard.backup_inventory() + + self.assertEqual([item["name"] for item in backups], [valid.name]) + self.assertEqual(backups[0]["sha256"], "a" * 64) + self.assertTrue(backups[0]["download_url"].startswith("/api/backups/download/")) + if __name__ == "__main__": unittest.main() diff --git a/dev/test_recovery.py b/dev/test_recovery.py index 2916185..c897bb2 100644 --- a/dev/test_recovery.py +++ b/dev/test_recovery.py @@ -101,6 +101,26 @@ class RecoveryScriptTests(unittest.TestCase): self.assertIn('start_proxy 9443 portainer:9443', gateway) self.assertIn('portainer-data:/backup/volumes/portainer-data:ro', compose) + def test_disaster_recovery_covers_all_three_scenarios_without_formatting(self) -> None: + script = (ROOT / "disaster-recovery.sh").read_text(encoding="utf-8") + for scenario in ("system", "data", "all"): + self.assertIn(scenario, script) + self.assertIn("mountpoint -q /data", script) + self.assertIn("--portable", script) + for destructive in ("mkfs", "fdisk", "parted", "reboot", "shutdown"): + self.assertNotIn(f"{destructive} ", script) + + def test_backup_layers_include_code_and_irreplaceable_data(self) -> None: + compose = (ROOT / "compose.yaml").read_text(encoding="utf-8") + export = (ROOT / "platform/backup/athena-export-backup").read_text( + encoding="utf-8" + ) + self.assertIn("/opt/mike-ai:/backup/opt-mike-ai:ro", compose) + self.assertIn("/data/voice/applio/logs", export) + self.assertIn("/data/voice/applio/datasets", export) + self.assertIn("ATHENA_EXPORT_KEEP:-5", export) + self.assertNotIn("add_path /data/models", export) + if __name__ == "__main__": unittest.main() diff --git a/disaster-recovery.sh b/disaster-recovery.sh new file mode 100755 index 0000000..f316792 --- /dev/null +++ b/disaster-recovery.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# One-shot recovery orchestrator for system-disk, data-disk and total loss. +# It never partitions, formats, reboots or shuts down the host. +set -Eeuo pipefail +umask 077 + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCENARIO="" +ARCHIVE=/data/docker-backups/athena-latest.tar.gz +RECOVERY_CONFIG="" +INSTALL_CONFIG="" +SNAPSHOT=latest +PORTABLE="" +AGE_IDENTITY="" +WORK="" + +log() { printf '\n==> %s\n' "$*"; } +die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } +cleanup() { [[ -z $WORK ]] || rm -rf "$WORK"; } +trap cleanup EXIT + +usage() { + cat <<'EOF' +Systemplatte defekt, vorhandene /data-Platte: + sudo ./disaster-recovery.sh --scenario system \ + --archive /data/docker-backups/athena-latest.tar.gz + +Datenplatte defekt, Systemplatte vorhanden: + sudo ./disaster-recovery.sh --scenario data \ + --config /root/athena-recovery.env + +Beide Platten neu: + sudo ./disaster-recovery.sh --scenario all \ + --config /root/athena-recovery.env + +Alternativ bei Daten-/Totalausfall mit einem zuvor heruntergeladenen Paket: + sudo ./disaster-recovery.sh --scenario all \ + --portable /pfad/athena-portable-....tar.zst.age \ + --identity /root/athena-recovery-key.txt + +Voraussetzung: Debian ist installiert und die richtige, bereits formatierte +Datenpartition ist separat unter /data eingehängt. Dieses Skript formatiert +keine Datenträger und führt niemals selbst einen Neustart aus. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --scenario) SCENARIO=${2:-}; shift 2 ;; + --archive) ARCHIVE=${2:-}; shift 2 ;; + --config) RECOVERY_CONFIG=${2:-}; shift 2 ;; + --install-config) INSTALL_CONFIG=${2:-}; shift 2 ;; + --snapshot) SNAPSHOT=${2:-}; shift 2 ;; + --portable) PORTABLE=${2:-}; shift 2 ;; + --identity) AGE_IDENTITY=${2:-}; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "Unbekanntes Argument: $1" ;; + esac +done + +[[ $EUID -eq 0 ]] || die "Bitte als root ausführen." +[[ $SCENARIO == system || $SCENARIO == data || $SCENARIO == all ]] || \ + die "--scenario muss system, data oder all sein." +mountpoint -q /data || die "/data ist kein eigener Mountpoint. Abbruch zum Schutz der Systemplatte." + +install_bootstrap_packages() { + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates gzip rsync tar restic +} + +copy_tree() { + local source=$1 target=$2 + [[ -d $source ]] || return 0 + install -d -m 0755 "$target" + rsync -a "$source/" "$target/" +} + +restore_local_bootstrap() { + [[ -s $ARCHIVE ]] || die "Lokales Backup fehlt: $ARCHIVE" + gzip -t "$ARCHIVE" || die "Lokales Backup ist beschädigt." + WORK=$(mktemp -d /tmp/athena-system-recovery.XXXXXX) + tar -xzf "$ARCHIVE" -C "$WORK" + [[ -d $WORK/backup/etc-mike-ai ]] || die "Backup enthält /etc/mike-ai nicht." + copy_tree "$WORK/backup/etc-mike-ai" /etc/mike-ai + if [[ -d $WORK/backup/opt-mike-ai ]]; then + copy_tree "$WORK/backup/opt-mike-ai" /opt/mike-ai + elif [[ -d $WORK/backup/stack ]]; then + copy_tree "$WORK/backup/stack" /opt/mike-ai/stack + fi + install -d -m 0700 /var/lib/mike-ai-disaster-backup/latest + install -m 0600 "$ARCHIVE" \ + /var/lib/mike-ai-disaster-backup/latest/docker-state.tar.gz +} + +restore_external_snapshot() { + [[ -r $RECOVERY_CONFIG ]] || die "Externe Recovery-Konfiguration fehlt: $RECOVERY_CONFIG" + # shellcheck disable=SC1090 + source "$RECOVERY_CONFIG" + [[ -n ${RESTIC_REPOSITORY:-} ]] || die "RESTIC_REPOSITORY fehlt." + [[ -n ${RESTIC_PASSWORD_FILE:-} && -r $RESTIC_PASSWORD_FILE ]] || die \ + "Der separat aufzubewahrende Restic-Schlüssel fehlt." + WORK=$(mktemp -d /tmp/athena-offsite-recovery.XXXXXX) + restic restore "$SNAPSHOT" --tag "${RESTIC_TAG:-athena-disaster}" --target "$WORK" + [[ -d $WORK/data ]] || die "Snapshot enthält keine Athena-Daten." + copy_tree "$WORK/data" /data + if [[ $SCENARIO == all ]]; then + copy_tree "$WORK/etc/mike-ai" /etc/mike-ai + copy_tree "$WORK/opt/mike-ai" /opt/mike-ai + fi +} + +restore_portable_archive() { + [[ -s $PORTABLE ]] || die "Portables Backup fehlt: $PORTABLE" + [[ -r $AGE_IDENTITY ]] || die "Age-Identität fehlt: $AGE_IDENTITY" + WORK=$(mktemp -d /tmp/athena-portable-recovery.XXXXXX) + age --decrypt -i "$AGE_IDENTITY" "$PORTABLE" | zstd -d | tar -xf - -C "$WORK" + [[ -d $WORK/data ]] || die "Portables Backup enthält keine Athena-Daten." + copy_tree "$WORK/data" /data + if [[ $SCENARIO == all ]]; then + copy_tree "$WORK/etc/mike-ai" /etc/mike-ai + copy_tree "$WORK/opt/mike-ai" /opt/mike-ai + fi +} + +run_installer() { + local config=${INSTALL_CONFIG:-/etc/mike-ai/install.env} + [[ -r $config ]] || die \ + "Installationskonfiguration fehlt: $config (alternativ --install-config angeben)." + chmod 0600 "$config" + [[ -x /opt/mike-ai/stack/install.sh ]] || die "Wiederhergestellter Stack fehlt." + set +e + /opt/mike-ai/stack/install.sh --config "$config" + local rc=$? + set -e + if [[ $rc == 20 || $rc == 21 ]]; then + printf '\nEin kontrollierter Neustart ist für Treiber/Netzwerk nötig.\n' + printf 'Danach exakt denselben Disaster-Recovery-Befehl erneut ausführen.\n' + exit "$rc" + fi + [[ $rc == 0 ]] || die "Installer fehlgeschlagen (Exit $rc)." +} + +restore_docker_state() { + local state=/var/lib/mike-ai-disaster-backup/latest/docker-state.tar.gz + if [[ ! -s $state && -n $WORK ]]; then + state=$(find "$WORK/var/lib/mike-ai-disaster-backup/latest" \ + -maxdepth 1 -name docker-state.tar.gz -type f -print -quit 2>/dev/null || true) + fi + [[ -s $state ]] || die "Docker-Zustandsarchiv fehlt im Backup." + /opt/mike-ai/stack/restore.sh --check "$state" + /opt/mike-ai/stack/restore.sh "$state" +} + +install_bootstrap_packages +if [[ $SCENARIO == system ]]; then + restore_local_bootstrap +elif [[ -n $PORTABLE ]]; then + restore_portable_archive +else + restore_external_snapshot +fi + +# In the data-only case the source/configuration remain on the system disk. +[[ -x /opt/mike-ai/stack/install.sh ]] || die "/opt/mike-ai/stack fehlt." +run_installer +restore_docker_state +/opt/mike-ai/stack/platform/recovery/rebuild-specialized.sh +/opt/mike-ai/stack/smoke-test.sh + +printf '\nATHENA_DISASTER_RECOVERY_OK scenario=%s\n' "$SCENARIO" +printf 'Athena läuft im LLM-Standardmodus; Spezial-GPU-Worker bleiben gestoppt.\n' diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index 977d63a..31f3f7d 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -1,102 +1,205 @@ -# Backup und Wiederherstellung +# Backup und vollständige Wiederherstellung -## Athena +Athena besitzt zwei voneinander unabhängige Sicherungsebenen. Nur gemeinsam +decken sie Systemplatten-, Datenplatten- und Totalausfall ab. -`mike-ai-backup` erzeugt alle fünf Stunden ein Archiv unter -`/data/docker-backups` und behält 14 Tage. Gesichert werden: +## Sicherungsebenen -- `/etc/mike-ai` mit lokaler Konfiguration, -- Router-Zustand und erzeugte Bilder, -- der kanonische Stack als zusätzlicher Snapshot. +| Ebene | Ziel | Takt | Zweck | +|---|---|---:|---| +| Lokales Schnellbackup | `/data/docker-backups` | alle 5 Stunden | schneller Wiederaufbau, wenn nur die Systemplatte stirbt | +| Verschlüsseltes Disaster-Backup | externes Restic-Repository, bevorzugt Unraid | nachts | Wiederaufbau, wenn `/data` oder beide Platten sterben | -Nicht in das Archiv gehören die großen Modellgewichte unter `/data/models`. -Sie bleiben auf der Daten-SSD oder werden anhand der gepinnten Angaben in -`config/install.env.example` erneut geladen. Die Dashboard-Historie liegt -dauerhaft unter `/data/llama-dashboard`. +Ein Backup, das ausschließlich auf `/data` liegt, schützt ausdrücklich nicht +vor dem Ausfall der Datenplatte. -Für FLUX.2 Klein 9B müssen vor einem erneuten Download die Bedingungen der -beiden Black-Forest-Labs-Repositories im Hugging-Face-Konto akzeptiert sein. -Außerdem muss die in `HF_TOKEN_FILE` angegebene Token-Datei wiederhergestellt -oder neu erzeugt werden. Der Token selbst ist absichtlich nicht Bestandteil -des Git-Repositories oder des Athena-Backups. +### Lokales Schnellbackup -Portainers lokale Konfiguration liegt im Docker-Volume `portainer_data` und -wird zusammen mit den übrigen nicht reproduzierbaren Volumes gesichert und -wiederhergestellt. +`mike-ai-backup` sichert: -### Neuaufbau +- `/etc/mike-ai`, einschließlich `install.env`, WireGuard und Geheimnissen, +- ganz `/opt/mike-ai`, einschließlich aller bereitgestellten Spezialprojekte, +- Router-Zustand und Router-Bilder, +- Portainer-Daten. -1. Debian installieren und `/data` wieder am bisherigen Pfad einhängen. -2. Dieses Repository klonen. -3. Installationsdatei ausfüllen und Installation starten: +Das Whisper-Volume ist reproduzierbar und wird bei Bedarf erneut geladen. +Ein vorhandener Hugging-Face-Token wird als root-only +`/etc/mike-ai/huggingface-token` mitgesichert, damit auch zugriffsbeschränkte +FLUX-Gewichte nach einem Datenverlust automatisch erneut geladen werden +können. Er steht niemals im Git-Repository. + +### Externes Disaster-Backup + +`athena-disaster-backup.timer` startet nachts ein verschlüsseltes, +dedupliziertes Restic-Backup. Vor jedem Lauf erzeugt es ein konsistentes +Docker-Schnellbackup und nimmt dieses in den externen Snapshot auf. Gesichert +werden außerdem: + +- `/etc/mike-ai` und `/opt/mike-ai`, +- eigene Stimmen, Applio-Datasets und Trainingsstände unter `/data/voice`, +- Musikprojekte und Ausgaben unter `/data/music`, +- Audio-Trennungen unter `/data/audio`, +- Dashboard-, Operator-, Benchmark- und Projektdaten. + +Die rund 100 GB reproduzierbaren Modellgewichte unter `/data/models` werden +nicht extern dupliziert. Kerngewichte lädt `install.sh` anhand URL und SHA256 +neu. Spezialmodelle laden ihre gepinnten Container beim ersten Start erneut. + +### Herunterladbare Notfallpakete + +Zusätzlich erzeugt `athena-export-backup.timer` alle fünf Stunden ein mit Age +verschlüsseltes Komplettpaket der unersetzlichen Daten unter +`/data/emergency-backups`. Das Dashboard zeigt die letzten fünf Generationen +mit Größe, SHA256-Prüfsumme und einem fortsetzbaren Download an. Enthalten sind +insbesondere Applio-Logs und -Checkpoints, Datasets, eigene Stimmen, +Musikprojekte, Audioergebnisse, Konfiguration, Docker-Zustand und sämtliche +bereitgestellten Quellstände. Erneut ladbare Modell- und Hugging-Face-Caches +sind ausgeschlossen. + +Bei aktuellem Datenbestand ist mit ungefähr 16 bis 20 GB je Generation zu +rechnen. Fünf Generationen benötigen daher grob 80 bis 100 GB auf `/data`. +Diese Pakete schützen nur dann vor einem Datenplattenausfall, wenn mindestens +eine Generation tatsächlich auf einen anderen Rechner oder Datenträger +heruntergeladen wurde. Die Pakete auf `/data` selbst sterben mit `/data`. + +Der zu `recovery.age-recipient` gehörende private Age-Schlüssel darf nicht auf +Athena verbleiben. Ohne ihn können die Pakete absichtlich nicht entschlüsselt +werden. + +## Einmalige Einrichtung des externen Backups + +1. Ein physisch anderes Backupziel bereitstellen, vorzugsweise einen + ausschließlich über WireGuard erreichbaren Unraid-Share, und zum Beispiel + unter `/mnt/athena-offsite` einhängen. +2. Eine starke Restic-Passphrase erzeugen und **zusätzlich außerhalb Athenas** + in einem Passwortmanager oder auf einem Recovery-USB verwahren. +3. Konfiguration anlegen: ```bash - sudo ./install.sh --config /root/mike-ai-install.env + cp config/disaster-backup.env.example /etc/mike-ai/disaster-backup.env + chmod 600 /etc/mike-ai/disaster-backup.env + # Repository, Mountpoint und Passwortdatei eintragen; danach: + sed -i 's/^DISASTER_BACKUP_ENABLED=false/DISASTER_BACKUP_ENABLED=true/' \ + /etc/mike-ai/disaster-backup.env ``` -4. Letztes Datenarchiv einspielen: +4. Ersten Lauf und Snapshot prüfen: ```bash - sudo ./restore.sh --check /data/docker-backups/athena-latest.tar.gz - sudo ./restore.sh /data/docker-backups/athena-latest.tar.gz - sudo ./smoke-test.sh + systemctl start athena-disaster-backup.service + journalctl -u athena-disaster-backup.service --no-pager + restic snapshots --tag athena-disaster ``` -`--check` liest das komplette gzip-Archiv und prüft dessen sichere -`/backup`-Struktur sowie die benötigten Konfigurations- und Volume-Bäume, ohne -Container oder Dateien zu verändern. Der reguläre Restore extrahiert und -verwendet anschließend ausschließlich diesen einen geprüften Baum. +Die externe Recovery-Konfiguration und die Passphrase bilden den kleinen +Recovery-Schlüssel. Eine Kopie davon muss außerhalb beider Athena-Platten +liegen. Ohne extern erreichbares Repository und dessen Schlüssel ist ein +Totalausfall mathematisch nicht wiederherstellbar. -Das Restore verändert weder SSH noch LAN, WireGuard, Kernel, Partitionen oder -Mounts. +## Gemeinsame Voraussetzung aller drei Fälle + +Debian 13 ist frisch beziehungsweise weiterhin vorhanden. Die korrekte +Datenpartition ist formatiert und als **eigener Mountpoint** `/data` +eingehängt. `disaster-recovery.sh` partitioniert und formatiert absichtlich +nichts und bricht ab, wenn `/data` nur ein Verzeichnis auf der Systemplatte +ist. Dadurch kann es nicht versehentlich die falsche Platte überschreiben. + +Der Installer darf einen kontrollierten Neustart für NVIDIA-Treiber oder die +stabile Netzwerkschnittstelle verlangen. Das Recovery-Skript startet Athena +niemals selbst neu. Nach dem manuellen Neustart wird derselbe Befehl erneut +ausgeführt; alle Schritte sind idempotent. + +## Fall 1: Systemplatte defekt, Datenplatte erhalten + +Nach Debian-Installation und Einhängen der alten `/data`-Platte: + +```bash +sudo ./disaster-recovery.sh --scenario system \ + --archive /data/docker-backups/athena-latest.tar.gz +``` + +Das Skript birgt Konfiguration und sämtliche `/opt/mike-ai`-Projekte aus dem +lokalen Archiv, installiert Docker/NVIDIA, verwendet die vorhandenen Modelle, +stellt die Docker-Volumes wieder her, baut Spezialcontainer und führt den +Smoke-Test aus. + +Ältere Archive vor Einführung von `/etc/mike-ai/install.env` bleiben lesbar. +Bei einem solchen Archiv muss die Installationsdatei einmal separat angegeben +werden: + +```bash +sudo ./disaster-recovery.sh --scenario system \ + --archive /data/docker-backups/athena-latest.tar.gz \ + --install-config /root/mike-ai-install.env +``` + +## Fall 2: Datenplatte defekt, Systemplatte erhalten + +Neue Datenpartition unter `/data` einhängen und den extern aufbewahrten +Recovery-Schlüssel bereitstellen: + +```bash +sudo ./disaster-recovery.sh --scenario data \ + --config /root/athena-recovery.env +``` + +Eigene Daten und der letzte Docker-Zustand kommen aus Restic. Modellgewichte +werden anschließend automatisch neu geladen. Je nach Internetverbindung ist +dies der längste Teil der Wiederherstellung. + +## Fall 3: Beide Platten defekt + +Debian auf der neuen Systemplatte installieren, neue Datenpartition als +`/data` einhängen, dieses Git-Repository klonen und den externen +Recovery-Schlüssel bereitstellen: + +```bash +sudo ./disaster-recovery.sh --scenario all \ + --config /root/athena-recovery.env +``` + +Der externe Snapshot liefert Installationskonfiguration, Schlüssel, +Anwendungsquellen, Spezial-UIs, eigene Daten und Docker-Zustand. Danach werden +Pakete, Images und Modellgewichte reproduzierbar neu aufgebaut. + +Alternativ kann ein zuvor aus dem Dashboard heruntergeladenes Notfallpaket +direkt verwendet werden: + +```bash +sudo ./disaster-recovery.sh --scenario all \ + --portable /mnt/usb/athena-portable-2026-09-10T15-00-00Z.tar.zst.age \ + --identity /mnt/usb/athena-recovery-key.txt +``` + +## Ergebnis und Sicherheitsverhalten + +Nach erfolgreichem Lauf gilt: + +- Kernstack und Dashboard laufen, +- Medium ist das aktive LLM-Standardprofil, +- Spezialcontainer und ihre Oberflächen sind gebaut beziehungsweise erstellt, +- GPU-intensive Spezialworker bleiben gestoppt, +- keine automatische Umschaltung in Musik-, Bild-, Voice- oder Applio-Modus, +- `smoke-test.sh` hat den Kern geprüft. + +Erst danach wird der gewünschte Spezialmodus über das Dashboard aktiviert. + +## Regelmäßige Prüfung + +Mindestens vierteljährlich einen Restore in eine leere Test-VM beziehungsweise +auf Testdatenträger durchführen. Ein grünes Backup-Log beweist nur, dass Daten +geschrieben wurden; erst ein Restore-Test beweist Wiederherstellbarkeit. + +```bash +systemctl status athena-disaster-backup.timer +journalctl -u athena-disaster-backup.service --since '2 days ago' +restic snapshots --tag athena-disaster +sudo ./restore.sh --check /data/docker-backups/athena-latest.tar.gz +``` ## Unraid -Hermes und die Fach-MCPs sind kein Bestandteil des Athena-Backups. Sie werden -durch das vorhandene Unraid-Appdata-Backup gesichert: - -- `/mnt/nvme-storage/appdata/Hermes-Agent` -- die jeweiligen Appdata-Verzeichnisse der MCP-Container -- DockerMan-Templates unter - `/boot/config/plugins/dockerMan/templates-user/` - -Container-Images stammen aus den dokumentierten Registries beziehungsweise den -eigenen Gitea-Repositories. Damit besteht die Wiederherstellung aus -Appdata-Restore plus Neuerstellung über die jeweilige Template-XML. - -### Hermes Cron/Bot-Chat auf Unraid - -Der offizielle Hermes-Build `0.21.0` mit Upstream-Stand `4b30b917` entfernt im -Cron-Zustellprozess fälschlich `HERMES_HOME`. Bei einem Docker-Datenverzeichnis -unter `/opt/data` findet `deliver=bot-chat:` dadurch vorhandene Profile -nicht. Bis zur Übernahme des Upstream-Fixes bindet die Unraid-Vorlage dieses -idempotente Startskript ein: - -- Host: `/mnt/nvme-storage/appdata/Hermes-Agent/patches/025-cron-profile-root-fix` -- Container: `/etc/cont-init.d/025-cron-profile-root-fix` (read-only) -- Quelle: `platform/hermes/025-cron-profile-root-fix` - -Das Skript entfernt nur die bekannte fehlerhafte Zeile. Ist sie in einem neuen -Image nicht mehr vorhanden, bleibt der Workaround automatisch wirkungslos. Es -stellt außerdem `/usr/local/bin/hermes` wieder her, weil der offizielle -Container den vom eigenen Doctor erwarteten CLI-Link derzeit nicht anlegt. - -Nach einem Restore die Datei mit Modus `0755` ins Appdata kopieren, den Mount in -der DockerMan-Vorlage kontrollieren und den Container neu erstellen. Prüfung: - -```bash -docker logs Hermes-Agent 2>&1 | grep cron-profile-root-fix -docker exec Hermes-Agent hermes cron doctor -``` - -## Kontrolle - -```bash -docker compose --env-file /etc/mike-ai/stack.env ps -sudo ./restore.sh --check /data/docker-backups/athena-latest.tar.gz -curl -fsS http://192.168.1.212:8099/health -sudo ./smoke-test.sh -``` - -Anschließend einen Hermes-Chat, einen Router-Aufruf und je eine kleine -read-only-Abfrage der benötigten MCPs testen. +Hermes und die Fach-MCPs laufen auf Unraid und sind kein Bestandteil des +Athena-Restores. Sie werden weiterhin über das Unraid-Appdata-Backup gesichert. +Das Athena-Disaster-Repository muss auf einem anderen Datenträger beziehungsweise +Storage-Pool als das zu schützende Athena-System liegen. diff --git a/install.sh b/install.sh index 2790df5..3a1546e 100755 --- a/install.sh +++ b/install.sh @@ -37,6 +37,10 @@ if (( (8#$config_mode & 077) != 0 )); then fi # shellcheck disable=SC1090 source "$CONFIG" +if [[ -n ${HF_TOKEN_FILE:-} && ! -r ${HF_TOKEN_FILE:-} && \ + -r /etc/mike-ai/huggingface-token ]]; then + HF_TOKEN_FILE=/etc/mike-ai/huggingface-token +fi required=(AI_HOSTNAME ADMIN_USER MODEL_DIR FAST_MODEL_FILE FAST_MODEL_URL FAST_MODEL_SHA256 MEDIUM_MODEL_FILE MEDIUM_MODEL_URL @@ -69,7 +73,7 @@ install_base_packages() { apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ ca-certificates curl git gnupg jq openssl wireguard-tools iptables \ - iproute2 pciutils rsync unattended-upgrades ethtool age + iproute2 pciutils rsync unattended-upgrades ethtool age restic zstd } setup_stable_network_name() { @@ -303,6 +307,19 @@ install_stack_files() { printf '%s\n' "$source_head" >"$STACK_DIR/.mike-ai-source-commit" fi install -d -m 0700 "$SECRETS_DIR" + # Keep the exact host bootstrap inputs with the protected system + # configuration. This breaks the former recovery cycle in which a fresh + # host needed a lost /root-only install file before it could restore backup. + if [[ $(realpath "$CONFIG") != $(realpath -m "$SECRETS_DIR/install.env") ]]; then + install -m 0600 "$CONFIG" "$SECRETS_DIR/install.env" + else + chmod 0600 "$SECRETS_DIR/install.env" + fi + if [[ -n ${HF_TOKEN_FILE:-} && -r $HF_TOKEN_FILE && \ + $(realpath "$HF_TOKEN_FILE") != $(realpath -m "$SECRETS_DIR/huggingface-token") ]]; then + install -m 0600 "$HF_TOKEN_FILE" "$SECRETS_DIR/huggingface-token" + HF_TOKEN_FILE=$SECRETS_DIR/huggingface-token + fi [[ -s $SECRETS_DIR/router-api-key ]] || openssl rand -base64 48 >$SECRETS_DIR/router-api-key [[ -s $SECRETS_DIR/controller-token ]] || openssl rand -base64 48 >$SECRETS_DIR/controller-token chmod 0600 "$SECRETS_DIR"/* @@ -366,6 +383,29 @@ EOF chmod 0600 $SECRETS_DIR/stack.env } +install_disaster_backup() { + log "Externes Disaster-Backup installieren" + install -m 0755 "$ROOT_DIR/platform/backup/athena-disaster-backup" \ + /usr/local/sbin/athena-disaster-backup + install -m 0644 "$ROOT_DIR/platform/backup/athena-disaster-backup.service" \ + /etc/systemd/system/athena-disaster-backup.service + install -m 0644 "$ROOT_DIR/platform/backup/athena-disaster-backup.timer" \ + /etc/systemd/system/athena-disaster-backup.timer + install -m 0755 "$ROOT_DIR/platform/backup/athena-export-backup" \ + /usr/local/sbin/athena-export-backup + install -m 0644 "$ROOT_DIR/platform/backup/athena-export-backup.service" \ + /etc/systemd/system/athena-export-backup.service + install -m 0644 "$ROOT_DIR/platform/backup/athena-export-backup.timer" \ + /etc/systemd/system/athena-export-backup.timer + if [[ ! -e $SECRETS_DIR/disaster-backup.env ]]; then + install -m 0600 "$ROOT_DIR/config/disaster-backup.env.example" \ + "$SECRETS_DIR/disaster-backup.env.example" + fi + systemctl daemon-reload + systemctl enable --now athena-disaster-backup.timer + systemctl enable --now athena-export-backup.timer +} + download_one() { local relative=$1 url=$2 expected=$3 target="$MODEL_DIR/$1" install -d -m 0755 "$(dirname "$target")" @@ -569,6 +609,7 @@ install_stack_files download_models install_routing_guard build_and_start +install_disaster_backup log "Installation abgeschlossen" if [[ ${WIREGUARD_MODE:-container} == container ]]; then diff --git a/platform/backup/athena-disaster-backup b/platform/backup/athena-disaster-backup new file mode 100755 index 0000000..a3e6f74 --- /dev/null +++ b/platform/backup/athena-disaster-backup @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Encrypted off-host backup for data-disk and total-loss recovery. +set -Eeuo pipefail +umask 077 + +CONFIG=${DISASTER_BACKUP_CONFIG:-/etc/mike-ai/disaster-backup.env} +STATE=/var/lib/mike-ai-disaster-backup + +log() { printf '\n==> %s\n' "$*"; } +die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "Bitte als root ausführen." +[[ -r $CONFIG ]] || die "Konfiguration fehlt: $CONFIG" +# shellcheck disable=SC1090 +source "$CONFIG" +[[ ${DISASTER_BACKUP_ENABLED:-false} == true ]] || die \ + "Externes Backup ist noch nicht freigeschaltet (DISASTER_BACKUP_ENABLED=true)." +[[ -n ${RESTIC_REPOSITORY:-} ]] || die "RESTIC_REPOSITORY fehlt." +if [[ -n ${RESTIC_REQUIRE_MOUNT:-} ]]; then + mountpoint -q "$RESTIC_REQUIRE_MOUNT" || die \ + "Externes Backupziel ist nicht eingehängt: $RESTIC_REQUIRE_MOUNT" +fi +[[ -n ${RESTIC_PASSWORD_FILE:-} && -r $RESTIC_PASSWORD_FILE ]] || die \ + "RESTIC_PASSWORD_FILE fehlt oder ist nicht lesbar." +command -v restic >/dev/null || die "restic ist nicht installiert." +command -v docker >/dev/null || die "Docker ist nicht installiert." +exec 9>/run/lock/athena-disaster-backup.lock +flock -n 9 || die "Ein Disaster-Backup läuft bereits." + +install -d -m 0700 "$STATE/latest" + +log "Konsistentes Docker-Schnellbackup erzeugen" +docker inspect mike-ai-backup >/dev/null 2>&1 || die "mike-ai-backup fehlt." +docker exec mike-ai-backup backup +latest=$(readlink -f /data/docker-backups/athena-latest.tar.gz) +[[ -s $latest ]] || die "Lokales Docker-Backup wurde nicht erzeugt." +gzip -t "$latest" || die "Lokales Docker-Backup ist beschädigt." +install -m 0600 "$latest" "$STATE/latest/docker-state.tar.gz" +sha256sum "$STATE/latest/docker-state.tar.gz" >"$STATE/latest/docker-state.tar.gz.sha256" + +log "Wiederaufbau-Metadaten erfassen" +{ + printf 'created_utc=%s\n' "$(date -u +%FT%TZ)" + printf 'hostname=%s\n' "$(hostname)" + printf 'source_commit=%s\n' "$(git -C /opt/mike-ai/stack rev-parse HEAD 2>/dev/null || printf unknown)" + findmnt -rn -o SOURCE,UUID,FSTYPE,TARGET / /data 2>/dev/null || true +} >"$STATE/latest/manifest.txt" +find /data/models -type f -printf '%P\t%s\n' 2>/dev/null | sort \ + >"$STATE/latest/model-manifest.tsv" +docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' \ + >"$STATE/latest/container-manifest.tsv" + +paths=(/etc/mike-ai /opt/mike-ai "$STATE/latest") +for path in \ + /data/voice /data/music /data/audio /data/llama-dashboard \ + /data/mike-ai-operator /data/benchmarks /data/model-benchmarks \ + /data/image-comparison /data/backups /data/deploy-backups; do + [[ ! -e $path ]] || paths+=("$path") +done + +tag=${RESTIC_TAG:-athena-disaster} +log "Verschlüsseltes externes Backup schreiben" +if ! restic snapshots >/dev/null 2>&1; then + log "Neues Restic-Repository initialisieren" + restic init +fi +restic backup --tag "$tag" "${paths[@]}" + +log "Aufbewahrung anwenden" +restic forget --tag "$tag" \ + --keep-daily "${RESTIC_KEEP_DAILY:-14}" \ + --keep-weekly "${RESTIC_KEEP_WEEKLY:-8}" \ + --keep-monthly "${RESTIC_KEEP_MONTHLY:-12}" --prune + +log "Letzten Snapshot verifizieren" +restic snapshots --tag "$tag" --latest 1 +restic check +printf 'ATHENA_DISASTER_BACKUP_OK\n' diff --git a/platform/backup/athena-disaster-backup.service b/platform/backup/athena-disaster-backup.service new file mode 100644 index 0000000..8c0ef5f --- /dev/null +++ b/platform/backup/athena-disaster-backup.service @@ -0,0 +1,12 @@ +[Unit] +Description=Encrypted off-host disaster backup for Athena +After=docker.service network-online.target +Wants=network-online.target +ConditionPathExists=/etc/mike-ai/disaster-backup.env + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/athena-disaster-backup +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 diff --git a/platform/backup/athena-disaster-backup.timer b/platform/backup/athena-disaster-backup.timer new file mode 100644 index 0000000..7a3d6ac --- /dev/null +++ b/platform/backup/athena-disaster-backup.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Nightly Athena off-host disaster backup + +[Timer] +OnCalendar=*-*-* 03:15:00 +Persistent=true +RandomizedDelaySec=30m +Unit=athena-disaster-backup.service + +[Install] +WantedBy=timers.target diff --git a/platform/backup/athena-export-backup b/platform/backup/athena-export-backup new file mode 100755 index 0000000..6137acd --- /dev/null +++ b/platform/backup/athena-export-backup @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Build a browser-downloadable, encrypted archive of irreplaceable Athena data. +set -Eeuo pipefail +umask 077 + +OUTPUT_DIR=${ATHENA_EXPORT_DIR:-/data/emergency-backups} +RECIPIENT_FILE=${ATHENA_AGE_RECIPIENT_FILE:-/etc/mike-ai/recovery.age-recipient} +STATE=/var/lib/mike-ai-disaster-backup/latest +KEEP=${ATHENA_EXPORT_KEEP:-5} + +log() { printf '\n==> %s\n' "$*"; } +die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "Bitte als root ausführen." +[[ -s $RECIPIENT_FILE ]] || die "Age-Empfänger fehlt: $RECIPIENT_FILE" +[[ $KEEP =~ ^[1-9][0-9]*$ ]] || die "ATHENA_EXPORT_KEEP muss positiv sein." +for command in age zstd tar docker sha256sum flock; do + command -v "$command" >/dev/null || die "$command fehlt." +done +exec 9>/run/lock/athena-export-backup.lock +flock -n 9 || die "Ein exportierbares Backup läuft bereits." + +install -d -m 0755 "$OUTPUT_DIR" +install -d -m 0700 "$STATE" + +log "Aktuellen Docker-Zustand sichern" +docker exec mike-ai-backup backup +latest=$(readlink -f /data/docker-backups/athena-latest.tar.gz) +[[ -s $latest ]] || die "Docker-Zustandsbackup fehlt." +gzip -t "$latest" || die "Docker-Zustandsbackup ist beschädigt." +install -m 0600 "$latest" "$STATE/docker-state.tar.gz" + +stamp=$(date -u +%Y-%m-%dT%H-%M-%SZ) +name="athena-portable-$stamp.tar.zst.age" +partial="$OUTPUT_DIR/.$name.partial" +target="$OUTPUT_DIR/$name" +list=$(mktemp /tmp/athena-export-list.XXXXXX) +trap 'rm -f "$list" "$partial"' EXIT + +add_path() { + local path=${1#/} + [[ ! -e /$path ]] || printf '%s\0' "$path" >>"$list" +} + +# Reproducible model/HF caches are deliberately omitted. Everything below is +# either a host configuration, project source, user input or generated result. +add_path /etc/mike-ai +add_path /opt/mike-ai +add_path /var/lib/mike-ai-disaster-backup/latest +add_path /data/voice/applio/logs +add_path /data/voice/applio/datasets +add_path /data/voice/applio/config.json +add_path /data/voice/omnivoice/output +add_path /data/voice/xvc/output +add_path /data/voice/studio +add_path /data/music +add_path /data/audio +add_path /data/llama-dashboard +add_path /data/mike-ai-operator +add_path /data/benchmarks +add_path /data/model-benchmarks +add_path /data/image-comparison +add_path /data/backups +add_path /data/deploy-backups + +log "Portables, verschlüsseltes Backup erzeugen" +tar --create --numeric-owner --acls --xattrs -C / --null --files-from="$list" \ + | zstd -T0 -3 \ + | age -R "$RECIPIENT_FILE" -o "$partial" +chmod 0644 "$partial" +mv "$partial" "$target" +sha256sum "$target" >"$target.sha256" +chmod 0644 "$target.sha256" + +log "Nur die letzten $KEEP Generationen behalten" +mapfile -t old < <(find "$OUTPUT_DIR" -maxdepth 1 -type f \ + -name 'athena-portable-*.tar.zst.age' -printf '%T@ %p\n' | sort -rn | tail -n +$((KEEP + 1)) | cut -d' ' -f2-) +for archive in "${old[@]}"; do + rm -f -- "$archive" "$archive.sha256" +done + +printf 'ATHENA_EXPORT_BACKUP_OK file=%s bytes=%s\n' "$target" "$(stat -c %s "$target")" diff --git a/platform/backup/athena-export-backup.service b/platform/backup/athena-export-backup.service new file mode 100644 index 0000000..22aed17 --- /dev/null +++ b/platform/backup/athena-export-backup.service @@ -0,0 +1,12 @@ +[Unit] +Description=Create encrypted downloadable Athena recovery package +After=docker.service +Requires=docker.service +ConditionPathExists=/etc/mike-ai/recovery.age-recipient + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/athena-export-backup +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 diff --git a/platform/backup/athena-export-backup.timer b/platform/backup/athena-export-backup.timer new file mode 100644 index 0000000..d414466 --- /dev/null +++ b/platform/backup/athena-export-backup.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Create an Athena recovery package every five hours + +[Timer] +OnBootSec=45m +OnUnitActiveSec=5h +Persistent=true +RandomizedDelaySec=10m +Unit=athena-export-backup.service + +[Install] +WantedBy=timers.target diff --git a/platform/llama-dashboard/app.py b/platform/llama-dashboard/app.py index 496220e..6215a86 100644 --- a/platform/llama-dashboard/app.py +++ b/platform/llama-dashboard/app.py @@ -37,12 +37,40 @@ MIKES_APPLIO_UI_URL = os.getenv( HOST_PROC = Path(os.getenv("HOST_PROC", "/host/proc")) HOST_DATA = os.getenv("HOST_DATA", "/host/data") HOST_MODELS = Path(os.getenv("HOST_MODELS", "/host/models")) +BACKUP_DIR = Path(os.getenv("DASHBOARD_BACKUP_DIR", "/host/data/emergency-backups")) STARTED = time.time() HISTORY_DB = Path(os.getenv("DASHBOARD_HISTORY_DB", "/var/lib/llama-dashboard/history.sqlite3")) HISTORY_INTERVAL = max(5, int(os.getenv("DASHBOARD_HISTORY_INTERVAL", "15"))) DETAIL_RETENTION_DAYS = max(1, int(os.getenv("DASHBOARD_DETAIL_RETENTION_DAYS", "21"))) +def backup_inventory() -> list[dict[str, Any]]: + try: + candidates = sorted( + BACKUP_DIR.glob("athena-portable-*.tar.zst.age"), + key=lambda item: item.stat().st_mtime, + reverse=True, + )[:5] + except OSError: + return [] + result = [] + for path in candidates: + try: + stat = path.stat() + checksum_file = path.with_name(path.name + ".sha256") + checksum = _read_text(checksum_file).split(maxsplit=1)[0] + result.append({ + "name": path.name, + "size": stat.st_size, + "modified": stat.st_mtime, + "sha256": checksum if len(checksum) == 64 else None, + "download_url": "/api/backups/download/" + urllib.parse.quote(path.name), + }) + except OSError: + continue + return result + + def _number(value: str) -> int | float | None: value = value.strip() if not value or value.lower() in {"n/a", "[n/a]", "not supported"}: @@ -638,6 +666,7 @@ HTML = r''' .history-controls{display:flex;flex-wrap:wrap;gap:7px;margin:10px 0 14px}.history-controls button{border:1px solid var(--line);background:#09111b;color:var(--muted);padding:6px 10px;border-radius:8px;cursor:pointer}.history-controls button.active{color:var(--cyan);border-color:var(--cyan)}.chart-legend{display:flex;flex-wrap:wrap;gap:8px;margin:2px 0 10px}.chart-legend button{border:1px solid var(--series);background:#09111b;color:var(--text);padding:6px 10px;border-radius:8px;cursor:pointer}.chart-legend button::before{content:'';display:inline-block;width:10px;height:3px;background:var(--series);margin:0 7px 3px 0}.chart-legend button.off{opacity:.4;text-decoration:line-through}.chart{width:100%;height:250px;display:block}.token-total{font-size:24px;font-weight:750;margin-top:5px}.history-note{color:var(--muted);font-size:11px;margin-top:8px} .usage-list{display:grid;gap:13px;margin-top:8px}.usage-head{display:flex;justify-content:space-between;gap:14px;align-items:baseline}.usage-head b{font-size:16px}.usage-head span{color:var(--muted)}.usage-meta{display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:11px;margin-top:5px} .mode-row{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap}.mode-buttons,.mode-buttons span{display:flex;gap:9px;flex-wrap:wrap}.mode-buttons button,.mode-buttons a{border:1px solid var(--line);background:#09111b;color:var(--text);padding:9px 14px;border-radius:9px;cursor:pointer;text-decoration:none;font:inherit}.mode-buttons button.active{border-color:var(--cyan);color:var(--cyan);box-shadow:inset 0 0 0 1px #45d7ff33}.mode-buttons button:disabled{opacity:.45;cursor:wait}.mode-buttons span[hidden]{display:none}.mode-buttons a.stable{border-color:#66e3a466;color:var(--green)}.mode-buttons a.experimental{border-color:#ffc65c66;color:var(--amber)}.mode-error{color:var(--red)} +.download{display:inline-block;border:1px solid #66e3a466;color:var(--green);padding:6px 10px;border-radius:8px;text-decoration:none}.hash{font:11px ui-monospace,SFMono-Regular,monospace;color:var(--muted);word-break:break-all}
Mike AI · Live Telemetry

Athena llama.cpp Dashboard

verbinde …
@@ -673,6 +702,8 @@ HTML = r'''
Ereignisse seit Dashboard-Start
Noch keine Zustandsänderung
llama.cpp Laufzeitkonfiguration
–wird gelesen
Verfügbare GGUF-Dateien
–
DateiPfadGrößeGeändert
–
+
Notfall-Backups · herunterladen
+
Verschlüsselte portable Sicherungen
Unersetzliche Daten ohne erneut ladbare Modellgewichte · maximal fünf Generationen
ErstelltGrößeSHA256
Backups werden geladen …
Zum Wiederherstellen wird der separat verwahrte Age-Schlüssel benötigt.
'''.replace( "__MUSIC_ORIGINAL_UI_URL__", MUSIC_ORIGINAL_UI_URL ).replace("__MUSIC_COMMUNITY_UI_URL__", MUSIC_COMMUNITY_UI_URL @@ -810,6 +842,62 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def _send_backup(self, name: str) -> None: + # Generated names are deliberately strict; never expose an arbitrary + # host path through the dashboard. + if not name.startswith("athena-portable-") or not name.endswith(".tar.zst.age"): + self._send(404, b'{"error":"not found"}', "application/json") + return + if Path(name).name != name: + self._send(404, b'{"error":"not found"}', "application/json") + return + path = BACKUP_DIR / name + try: + size = path.stat().st_size + except OSError: + self._send(404, b'{"error":"not found"}', "application/json") + return + + start, end = 0, size - 1 + status = 200 + range_header = self.headers.get("Range", "") + if range_header: + try: + unit, raw = range_header.split("=", 1) + first, last = raw.split("-", 1) + if unit != "bytes" or "," in raw or not first: + raise ValueError + start = int(first) + end = min(size - 1, int(last)) if last else size - 1 + if start < 0 or start > end or start >= size: + raise ValueError + status = 206 + except ValueError: + self.send_response(416) + self.send_header("Content-Range", f"bytes */{size}") + self.end_headers() + return + + self.send_response(status) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Disposition", f'attachment; filename="{name}"') + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Length", str(end - start + 1)) + if status == 206: + self.send_header("Content-Range", f"bytes {start}-{end}/{size}") + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + remaining = end - start + 1 + with path.open("rb") as source: + source.seek(start) + while remaining: + chunk = source.read(min(1024 * 1024, remaining)) + if not chunk: + break + self.wfile.write(chunk) + remaining -= len(chunk) + def do_GET(self) -> None: path = self.path.split("?", 1)[0] if path == "/": @@ -828,6 +916,13 @@ class Handler(BaseHTTPRequestHandler): range_name = query.get("range", ["24h"])[0] body = json.dumps(HISTORY.query(range_name), ensure_ascii=False, separators=(",", ":")).encode() self._send(200, body, "application/json; charset=utf-8") + elif path == "/api/backups": + body = json.dumps({"backups": backup_inventory()}, ensure_ascii=False, + separators=(",", ":")).encode() + self._send(200, body, "application/json; charset=utf-8") + elif path.startswith("/api/backups/download/"): + name = urllib.parse.unquote(path.removeprefix("/api/backups/download/")) + self._send_backup(name) else: self._send(404, b'{"error":"not found"}', "application/json") diff --git a/platform/recovery/rebuild-specialized.sh b/platform/recovery/rebuild-specialized.sh new file mode 100755 index 0000000..d69aad7 --- /dev/null +++ b/platform/recovery/rebuild-specialized.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Rebuild/create specialist containers after a bare-metal restore. GPU workers +# remain stopped; the core installer leaves Athena safely in LLM mode. +set -Eeuo pipefail + +log() { printf '\n==> %s\n' "$*"; } + +if [[ -r /etc/mike-ai/install.env ]]; then + set -a + # shellcheck disable=SC1091 + source /etc/mike-ai/install.env + set +a +fi +export VOICE_GPU_UUID=${VOICE_GPU_UUID:-${IMAGE_GPU_DEVICES:-}} +export ACESTEP_GPU_UUID=${ACESTEP_GPU_UUID:-${IMAGE_GPU_DEVICES:-}} +export SEPARATOR_GPU_UUID=${SEPARATOR_GPU_UUID:-${IMAGE_GPU_DEVICES:-}} + +create_project() { + local dir=$1 file=${2:-compose.yaml} profile=${3:-} + [[ -f $dir/$file ]] || { printf 'Übersprungen (fehlt): %s/%s\n' "$dir" "$file"; return 0; } + log "Spezialprojekt vorbereiten: $dir" + local args=(docker compose -f "$file") + [[ -z $profile ]] || args+=(--profile "$profile") + (cd "$dir" && "${args[@]}" pull --ignore-buildable && \ + "${args[@]}" build && "${args[@]}" create) +} + +docker network inspect mike-ai_frontend >/dev/null +create_project /opt/mike-ai/acestep-test compose.yaml music-test +create_project /opt/mike-ai/stem-separator +create_project /opt/mike-ai/omnivoice-studio +create_project /opt/mike-ai/xvc-studio +create_project /opt/mike-ai/stack/experiments/applio-rvc +create_project /opt/mike-ai/Mikes-Applio-UI compose.example.yaml + +printf 'ATHENA_SPECIALISTS_REBUILT_OK\n' diff --git a/restore.sh b/restore.sh index 2507c50..13ad0d8 100755 --- a/restore.sh +++ b/restore.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Restore Athena's non-reproducible Docker state from the latest /data backup. +# Restore Athena's configuration, deployed source and non-reproducible Docker +# state from a local quick-recovery archive. set -Eeuo pipefail umask 077 @@ -69,6 +70,16 @@ done install -d -m 0700 /etc/mike-ai rsync -a --delete "$backup_root/etc-mike-ai/" /etc/mike-ai/ +# New archives contain all deployed projects. Accept the old stack-only layout +# as well so every already existing backup stays usable. +if [[ -d $backup_root/opt-mike-ai ]]; then + install -d -m 0755 /opt/mike-ai + rsync -a "$backup_root/opt-mike-ai/" /opt/mike-ai/ +elif [[ -d $backup_root/stack ]]; then + install -d -m 0755 /opt/mike-ai/stack + rsync -a "$backup_root/stack/" /opt/mike-ai/stack/ +fi + restore_volume() { local volume=$1 source=$2 mountpoint [[ -d $source ]] || return 0 @@ -82,7 +93,9 @@ restore_volume mike-ai_router-state "$backup_root/volumes/router-state" restore_volume mike-ai_router-images "$backup_root/volumes/router-images" restore_volume portainer_data "$backup_root/volumes/portainer-data" -cd "$ROOT_DIR" +deploy_root=/opt/mike-ai/stack +[[ -x $deploy_root/manage.sh ]] || deploy_root=$ROOT_DIR +cd "$deploy_root" ./manage.sh deploy core printf 'ATHENA_RESTORE_OK %s\n' "$ARCHIVE"