Add three-scenario disaster recovery

This commit is contained in:
Mikei386
2026-09-10 14:04:56 +02:00
parent e5e5d7fa4d
commit bc2ca9af7d
18 changed files with 853 additions and 107 deletions
+34 -15
View File
@@ -261,24 +261,43 @@ beweist deshalb nicht automatisch, dass unter /data keine alten Experiment-
oder Modelldateien mehr liegen. 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 1. mike-ai-backup schreibt alle fünf Stunden ein lokales Schnellbackup nach
- /opt/mike-ai/stack /data/docker-backups. Darin liegen /etc/mike-ai, ganz /opt/mike-ai sowie
- mike-ai_router-state Router- und Portainer-Zustand. Dieses Backup deckt den Ausfall der
- mike-ai_router-images Systemplatte ab, solange /data erhalten bleibt.
- portainer_data
Das Volume mike-ai_whisper-data wird nicht archiviert. Das Whisper-Modell wird 2. athena-disaster-backup schreibt nachts ein verschlüsseltes und
bei einem leeren Volume automatisch erneut heruntergeladen. Die großen dedupliziertes Restic-Backup auf einen physisch anderen Speicher. Es enthält
Hostverzeichnisse unter /data/models, /data/voice, /data/music und zusätzlich eigene Stimmen, Trainingsdatensätze, Musik, Audioergebnisse,
/data/audio sind ebenfalls nicht automatisch Bestandteil dieses Dashboard- und Projektdaten. Dieses Backup deckt den Ausfall der Datenplatte
Docker-Volume-Backups. Besonders eigene Trainingsdaten und trainierte und den gleichzeitigen Ausfall beider Platten ab.
Stimmenmodelle benötigen deshalb eine zusätzliche Datensicherung, wenn sie
nicht reproduzierbar sind. 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 ENTFERNTE KOMPONENTEN
+8 -6
View File
@@ -173,16 +173,18 @@ Unraid-DockerMan-Templates. Details stehen in
## Wiederherstellung ## 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 ```bash
sudo ./install.sh --config /root/mike-ai-install.env sudo ./disaster-recovery.sh --scenario system \
sudo ./restore.sh --check /data/docker-backups/athena-latest.tar.gz --archive /data/docker-backups/athena-latest.tar.gz
sudo ./restore.sh /data/docker-backups/athena-latest.tar.gz
sudo ./smoke-test.sh
``` ```
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 ## Verbindliche Dokumentation
+4 -1
View File
@@ -750,6 +750,7 @@ services:
HOST_PROC: /host/proc HOST_PROC: /host/proc
HOST_DATA: /host/data HOST_DATA: /host/data
HOST_MODELS: /host/models HOST_MODELS: /host/models
DASHBOARD_BACKUP_DIR: /host/data/emergency-backups
DASHBOARD_HISTORY_DB: /var/lib/llama-dashboard/history.sqlite3 DASHBOARD_HISTORY_DB: /var/lib/llama-dashboard/history.sqlite3
DASHBOARD_HISTORY_INTERVAL: "15" DASHBOARD_HISTORY_INTERVAL: "15"
DASHBOARD_DETAIL_RETENTION_DAYS: "21" DASHBOARD_DETAIL_RETENTION_DAYS: "21"
@@ -797,7 +798,9 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- /data/docker-backups:/archive - /data/docker-backups:/archive
- /etc/mike-ai:/backup/etc-mike-ai:ro - /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-state:/backup/volumes/router-state:ro
- router-images:/backup/volumes/router-images:ro - router-images:/backup/volumes/router-images:ro
- portainer-data:/backup/volumes/portainer-data:ro - portainer-data:/backup/volumes/portainer-data:ro
+18
View File
@@ -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
+17
View File
@@ -30,6 +30,7 @@ class DashboardModeTests(unittest.TestCase):
cls.tempdir = tempfile.TemporaryDirectory() cls.tempdir = tempfile.TemporaryDirectory()
with patch.dict(os.environ, { with patch.dict(os.environ, {
"DASHBOARD_HISTORY_DB": str(Path(cls.tempdir.name) / "history.sqlite3"), "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_URL": "http://router.test:8081",
"ROUTER_API_KEY": "test-key", "ROUTER_API_KEY": "test-key",
}): }):
@@ -37,6 +38,8 @@ class DashboardModeTests(unittest.TestCase):
cls.dashboard = importlib.util.module_from_spec(spec) cls.dashboard = importlib.util.module_from_spec(spec)
assert spec.loader is not None assert spec.loader is not None
spec.loader.exec_module(cls.dashboard) spec.loader.exec_module(cls.dashboard)
cls.backup_dir = Path(cls.tempdir.name) / "backups"
cls.backup_dir.mkdir()
@classmethod @classmethod
def tearDownClass(cls): 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:8011/", html)
self.assertIn("http://192.168.1.212:8012/", 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+20
View File
@@ -101,6 +101,26 @@ class RecoveryScriptTests(unittest.TestCase):
self.assertIn('start_proxy 9443 portainer:9443', gateway) self.assertIn('start_proxy 9443 portainer:9443', gateway)
self.assertIn('portainer-data:/backup/volumes/portainer-data:ro', compose) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+172
View File
@@ -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'
+185 -82
View File
@@ -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 ## Sicherungsebenen
`/data/docker-backups` und behält 14 Tage. Gesichert werden:
- `/etc/mike-ai` mit lokaler Konfiguration, | Ebene | Ziel | Takt | Zweck |
- Router-Zustand und erzeugte Bilder, |---|---|---:|---|
- der kanonische Stack als zusätzlicher Snapshot. | 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`. Ein Backup, das ausschließlich auf `/data` liegt, schützt ausdrücklich nicht
Sie bleiben auf der Daten-SSD oder werden anhand der gepinnten Angaben in vor dem Ausfall der Datenplatte.
`config/install.env.example` erneut geladen. Die Dashboard-Historie liegt
dauerhaft unter `/data/llama-dashboard`.
Für FLUX.2 Klein 9B müssen vor einem erneuten Download die Bedingungen der ### Lokales Schnellbackup
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.
Portainers lokale Konfiguration liegt im Docker-Volume `portainer_data` und `mike-ai-backup` sichert:
wird zusammen mit den übrigen nicht reproduzierbaren Volumes gesichert und
wiederhergestellt.
### 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. Das Whisper-Volume ist reproduzierbar und wird bei Bedarf erneut geladen.
2. Dieses Repository klonen. Ein vorhandener Hugging-Face-Token wird als root-only
3. Installationsdatei ausfüllen und Installation starten: `/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 ```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 ```bash
sudo ./restore.sh --check /data/docker-backups/athena-latest.tar.gz systemctl start athena-disaster-backup.service
sudo ./restore.sh /data/docker-backups/athena-latest.tar.gz journalctl -u athena-disaster-backup.service --no-pager
sudo ./smoke-test.sh restic snapshots --tag athena-disaster
``` ```
`--check` liest das komplette gzip-Archiv und prüft dessen sichere Die externe Recovery-Konfiguration und die Passphrase bilden den kleinen
`/backup`-Struktur sowie die benötigten Konfigurations- und Volume-Bäume, ohne Recovery-Schlüssel. Eine Kopie davon muss außerhalb beider Athena-Platten
Container oder Dateien zu verändern. Der reguläre Restore extrahiert und liegen. Ohne extern erreichbares Repository und dessen Schlüssel ist ein
verwendet anschließend ausschließlich diesen einen geprüften Baum. Totalausfall mathematisch nicht wiederherstellbar.
Das Restore verändert weder SSH noch LAN, WireGuard, Kernel, Partitionen oder ## Gemeinsame Voraussetzung aller drei Fälle
Mounts.
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 ## Unraid
Hermes und die Fach-MCPs sind kein Bestandteil des Athena-Backups. Sie werden Hermes und die Fach-MCPs laufen auf Unraid und sind kein Bestandteil des
durch das vorhandene Unraid-Appdata-Backup gesichert: Athena-Restores. Sie werden weiterhin über das Unraid-Appdata-Backup gesichert.
Das Athena-Disaster-Repository muss auf einem anderen Datenträger beziehungsweise
- `/mnt/nvme-storage/appdata/Hermes-Agent` Storage-Pool als das zu schützende Athena-System liegen.
- 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:<profil>` 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.
+42 -1
View File
@@ -37,6 +37,10 @@ if (( (8#$config_mode & 077) != 0 )); then
fi fi
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "$CONFIG" 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 required=(AI_HOSTNAME ADMIN_USER MODEL_DIR FAST_MODEL_FILE
FAST_MODEL_URL FAST_MODEL_SHA256 MEDIUM_MODEL_FILE MEDIUM_MODEL_URL FAST_MODEL_URL FAST_MODEL_SHA256 MEDIUM_MODEL_FILE MEDIUM_MODEL_URL
@@ -69,7 +73,7 @@ install_base_packages() {
apt-get update apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl git gnupg jq openssl wireguard-tools iptables \ 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() { setup_stable_network_name() {
@@ -303,6 +307,19 @@ install_stack_files() {
printf '%s\n' "$source_head" >"$STACK_DIR/.mike-ai-source-commit" printf '%s\n' "$source_head" >"$STACK_DIR/.mike-ai-source-commit"
fi fi
install -d -m 0700 "$SECRETS_DIR" 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/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 [[ -s $SECRETS_DIR/controller-token ]] || openssl rand -base64 48 >$SECRETS_DIR/controller-token
chmod 0600 "$SECRETS_DIR"/* chmod 0600 "$SECRETS_DIR"/*
@@ -366,6 +383,29 @@ EOF
chmod 0600 $SECRETS_DIR/stack.env 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() { download_one() {
local relative=$1 url=$2 expected=$3 target="$MODEL_DIR/$1" local relative=$1 url=$2 expected=$3 target="$MODEL_DIR/$1"
install -d -m 0755 "$(dirname "$target")" install -d -m 0755 "$(dirname "$target")"
@@ -569,6 +609,7 @@ install_stack_files
download_models download_models
install_routing_guard install_routing_guard
build_and_start build_and_start
install_disaster_backup
log "Installation abgeschlossen" log "Installation abgeschlossen"
if [[ ${WIREGUARD_MODE:-container} == container ]]; then if [[ ${WIREGUARD_MODE:-container} == container ]]; then
+78
View File
@@ -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'
@@ -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
@@ -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
+82
View File
@@ -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")"
@@ -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
@@ -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
+95
View File
@@ -37,12 +37,40 @@ MIKES_APPLIO_UI_URL = os.getenv(
HOST_PROC = Path(os.getenv("HOST_PROC", "/host/proc")) HOST_PROC = Path(os.getenv("HOST_PROC", "/host/proc"))
HOST_DATA = os.getenv("HOST_DATA", "/host/data") HOST_DATA = os.getenv("HOST_DATA", "/host/data")
HOST_MODELS = Path(os.getenv("HOST_MODELS", "/host/models")) HOST_MODELS = Path(os.getenv("HOST_MODELS", "/host/models"))
BACKUP_DIR = Path(os.getenv("DASHBOARD_BACKUP_DIR", "/host/data/emergency-backups"))
STARTED = time.time() STARTED = time.time()
HISTORY_DB = Path(os.getenv("DASHBOARD_HISTORY_DB", "/var/lib/llama-dashboard/history.sqlite3")) 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"))) HISTORY_INTERVAL = max(5, int(os.getenv("DASHBOARD_HISTORY_INTERVAL", "15")))
DETAIL_RETENTION_DAYS = max(1, int(os.getenv("DASHBOARD_DETAIL_RETENTION_DAYS", "21"))) 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: def _number(value: str) -> int | float | None:
value = value.strip() value = value.strip()
if not value or value.lower() in {"n/a", "[n/a]", "not supported"}: if not value or value.lower() in {"n/a", "[n/a]", "not supported"}:
@@ -638,6 +666,7 @@ HTML = r'''<!doctype html>
.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} .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} .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)} .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}
</style></head><body><main> </style></head><body><main>
<div class="top"><div><div class="eyebrow">Mike AI · Live Telemetry</div><h1>Athena llama.cpp Dashboard</h1></div><div class="live"><span class="dot" id="dot"></span><span id="updated">verbinde …</span></div></div> <div class="top"><div><div class="eyebrow">Mike AI · Live Telemetry</div><h1>Athena llama.cpp Dashboard</h1></div><div class="live"><span class="dot" id="dot"></span><span id="updated">verbinde …</span></div></div>
<section class="grid"> <section class="grid">
@@ -673,6 +702,8 @@ HTML = r'''<!doctype html>
<article class="card span6"><div class="label">Ereignisse seit Dashboard-Start</div><div id="events"><div class="sub">Noch keine Zustandsänderung</div></div></article> <article class="card span6"><div class="label">Ereignisse seit Dashboard-Start</div><div id="events"><div class="sub">Noch keine Zustandsänderung</div></div></article>
<article class="card span12"><div class="label">llama.cpp Laufzeitkonfiguration</div><div class="metrics" id="runtime"><div class="metric"><b>–</b><span>wird gelesen</span></div></div></article> <article class="card span12"><div class="label">llama.cpp Laufzeitkonfiguration</div><div class="metrics" id="runtime"><div class="metric"><b>–</b><span>wird gelesen</span></div></div></article>
<article class="card span12"><div class="row"><div><div class="label">Verfügbare GGUF-Dateien</div><div class="sub" id="modelSummary">–</div></div></div><div class="scroll"><table><thead><tr><th>Datei</th><th>Pfad</th><th>Größe</th><th>Geändert</th></tr></thead><tbody id="modelFiles"><tr><td colspan="4">–</td></tr></tbody></table></div></article> <article class="card span12"><div class="row"><div><div class="label">Verfügbare GGUF-Dateien</div><div class="sub" id="modelSummary">–</div></div></div><div class="scroll"><table><thead><tr><th>Datei</th><th>Pfad</th><th>Größe</th><th>Geändert</th></tr></thead><tbody id="modelFiles"><tr><td colspan="4">–</td></tr></tbody></table></div></article>
<div class="section-title">Notfall-Backups · herunterladen</div>
<article class="card span12"><div class="row"><div><div class="label">Verschlüsselte portable Sicherungen</div><div class="sub">Unersetzliche Daten ohne erneut ladbare Modellgewichte · maximal fünf Generationen</div></div></div><div class="scroll"><table><thead><tr><th>Erstellt</th><th>Größe</th><th>SHA256</th><th></th></tr></thead><tbody id="backupFiles"><tr><td colspan="4">Backups werden geladen …</td></tr></tbody></table></div><div class="sub" id="backupNote">Zum Wiederherstellen wird der separat verwahrte Age-Schlüssel benötigt.</div></article>
<article class="card span12 error" id="errors" hidden></article> <article class="card span12 error" id="errors" hidden></article>
</section><div class="footer">Aktualisierung jede Sekunde · Umschaltung über Athena Router</div> </section><div class="footer">Aktualisierung jede Sekunde · Umschaltung über Athena Router</div>
</main><script> </main><script>
@@ -682,6 +713,7 @@ const imagePhaseLabel=p=>({"stopping-qwen":"Qwen wird entladen","loading-image":
let modeBusy=false; let modeBusy=false;
async function setMode(mode){if(modeBusy)return;modeBusy=true;for(const id of ['llmMode','musicMode','separationMode','voiceMode','voiceChangeMode','applioMode'])$(id).disabled=true;$('modeStatus').textContent='Umschaltung angefordert …';try{let r=await fetch('/api/mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});let d=await r.json();if(!r.ok)throw Error(d?.error?.message||d?.error||`HTTP ${r.status}`);$('modeStatus').textContent='Umschaltung läuft …'}catch(e){$('modeStatus').textContent=e.message;$('modeStatus').classList.add('mode-error')}finally{modeBusy=false;setTimeout(refresh,250)}} async function setMode(mode){if(modeBusy)return;modeBusy=true;for(const id of ['llmMode','musicMode','separationMode','voiceMode','voiceChangeMode','applioMode'])$(id).disabled=true;$('modeStatus').textContent='Umschaltung angefordert …';try{let r=await fetch('/api/mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});let d=await r.json();if(!r.ok)throw Error(d?.error?.message||d?.error||`HTTP ${r.status}`);$('modeStatus').textContent='Umschaltung läuft …'}catch(e){$('modeStatus').textContent=e.message;$('modeStatus').classList.add('mode-error')}finally{modeBusy=false;setTimeout(refresh,250)}}
async function refresh(){try{let r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{},imageActive=img.phase&&img.phase!=='idle';let md=rt.mode||{},switchingMode=md.phase&&md.phase!=='ready',modeName=({llm:'LLM-Betrieb',music:'Musikstudio',separation:'Stimmtrennung',voice:'Voice Studio',voicechange:'X-VC Voice Changer',applio:'Applio / RVC'})[md.active]||'Unbekannt';$('operatingMode').textContent=modeName;$('modeStatus').textContent=switchingMode?`Umschaltung: ${md.phase}`:(md.last_error||`Musik: ${md.music_worker||'–'} · Separator: ${md.separator_worker||'–'} · Voice: ${md.voice_worker||'–'}${md.return_profile?` · Rückkehr zu ${md.return_profile}`:''}`);$('modeStatus').classList.toggle('mode-error',!!md.last_error);$('llmMode').classList.toggle('active',md.active==='llm');$('musicMode').classList.toggle('active',md.active==='music');$('separationMode').classList.toggle('active',md.active==='separation');$('voiceMode').classList.toggle('active',md.active==='voice');$('voiceChangeMode').classList.toggle('active',md.active==='voicechange');$('applioMode').classList.toggle('active',md.active==='applio');let modeControlsBusy=modeBusy||switchingMode||!md.enabled;for(const id of ['llmMode','musicMode','separationMode','voiceMode','voiceChangeMode','applioMode'])$(id).disabled=modeControlsBusy;$('musicOpen').hidden=md.active!=='music';$('separatorOpen').hidden=md.active!=='separation';$('voiceOpen').hidden=md.active!=='voice';$('voiceChangeOpen').hidden=md.active!=='voicechange';$('applioOpen').hidden=md.active!=='applio';$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');$('profileSub').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching?`Wechsel zu ${rt.switching}`:`Kontext: ${up.ctx?up.ctx.toLocaleString('de-DE'):'–'} Token`);$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');$('modelSub').textContent=imageActive?`${img.model_loaded?'geladen':'wird vorbereitet'} · Worker ${img.worker||'–'}`:(lr.model_file|| (up.reachable?'llama.cpp erreichbar':'llama.cpp nicht erreichbar'));$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;let rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(rp);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'<article class="card span12 error">Keine GPU-Daten verfügbar</article>';$('availability').textContent=imageActive?imagePhaseLabel(img.phase):(q.available?'bereit':'nicht bereit');$('availability').className=`value status ${(imageActive||q.available)?'':'bad'}`;$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);$('switching').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching||'nein');let disk=c.disk_data||{},dp=disk.total?disk.used/disk.total*100:null;$('dataDisk').textContent=pct(dp);$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`<tr><td>${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}</td><td>${p.name}</td><td>${p.pid}</td><td>${p.memory_mib??'–'} MiB</td></tr>`).join('')||'<tr><td colspan="4">Keine Compute-Prozesse gemeldet</td></tr>';let runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?lr.context_size.toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];$('runtime').innerHTML=runtime.map(([k,v])=>`<div class="metric"><b>${v??'–'}</b><span>${k}</span></div>`).join('');let es=Object.entries(d.errors||{}).filter(([,v])=>v);$('errors').hidden=!es.length;$('errors').textContent=es.map(([k,v])=>`${k}: ${v}`).join('\n');$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)'}catch(e){$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}}refresh();setInterval(refresh,1000); async function refresh(){try{let r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{},imageActive=img.phase&&img.phase!=='idle';let md=rt.mode||{},switchingMode=md.phase&&md.phase!=='ready',modeName=({llm:'LLM-Betrieb',music:'Musikstudio',separation:'Stimmtrennung',voice:'Voice Studio',voicechange:'X-VC Voice Changer',applio:'Applio / RVC'})[md.active]||'Unbekannt';$('operatingMode').textContent=modeName;$('modeStatus').textContent=switchingMode?`Umschaltung: ${md.phase}`:(md.last_error||`Musik: ${md.music_worker||'–'} · Separator: ${md.separator_worker||'–'} · Voice: ${md.voice_worker||'–'}${md.return_profile?` · Rückkehr zu ${md.return_profile}`:''}`);$('modeStatus').classList.toggle('mode-error',!!md.last_error);$('llmMode').classList.toggle('active',md.active==='llm');$('musicMode').classList.toggle('active',md.active==='music');$('separationMode').classList.toggle('active',md.active==='separation');$('voiceMode').classList.toggle('active',md.active==='voice');$('voiceChangeMode').classList.toggle('active',md.active==='voicechange');$('applioMode').classList.toggle('active',md.active==='applio');let modeControlsBusy=modeBusy||switchingMode||!md.enabled;for(const id of ['llmMode','musicMode','separationMode','voiceMode','voiceChangeMode','applioMode'])$(id).disabled=modeControlsBusy;$('musicOpen').hidden=md.active!=='music';$('separatorOpen').hidden=md.active!=='separation';$('voiceOpen').hidden=md.active!=='voice';$('voiceChangeOpen').hidden=md.active!=='voicechange';$('applioOpen').hidden=md.active!=='applio';$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');$('profileSub').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching?`Wechsel zu ${rt.switching}`:`Kontext: ${up.ctx?up.ctx.toLocaleString('de-DE'):'–'} Token`);$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');$('modelSub').textContent=imageActive?`${img.model_loaded?'geladen':'wird vorbereitet'} · Worker ${img.worker||'–'}`:(lr.model_file|| (up.reachable?'llama.cpp erreichbar':'llama.cpp nicht erreichbar'));$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;let rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(rp);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'<article class="card span12 error">Keine GPU-Daten verfügbar</article>';$('availability').textContent=imageActive?imagePhaseLabel(img.phase):(q.available?'bereit':'nicht bereit');$('availability').className=`value status ${(imageActive||q.available)?'':'bad'}`;$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);$('switching').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching||'nein');let disk=c.disk_data||{},dp=disk.total?disk.used/disk.total*100:null;$('dataDisk').textContent=pct(dp);$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`<tr><td>${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}</td><td>${p.name}</td><td>${p.pid}</td><td>${p.memory_mib??'–'} MiB</td></tr>`).join('')||'<tr><td colspan="4">Keine Compute-Prozesse gemeldet</td></tr>';let runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?lr.context_size.toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];$('runtime').innerHTML=runtime.map(([k,v])=>`<div class="metric"><b>${v??'–'}</b><span>${k}</span></div>`).join('');let es=Object.entries(d.errors||{}).filter(([,v])=>v);$('errors').hidden=!es.length;$('errors').textContent=es.map(([k,v])=>`${k}: ${v}`).join('\n');$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)'}catch(e){$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}}refresh();setInterval(refresh,1000);
async function refreshBackups(){try{let r=await fetch('/api/backups',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),rows=d.backups||[];$('backupFiles').innerHTML=rows.map(b=>`<tr><td>${new Date(b.modified*1000).toLocaleString('de-DE')}</td><td>${gib(b.size)}</td><td class="hash">${b.sha256||'Prüfsumme fehlt'}</td><td><a class="download" href="${b.download_url}">Herunterladen</a></td></tr>`).join('')||'<tr><td colspan="4">Noch kein portables Backup vorhanden</td></tr>';$('backupNote').textContent=rows.length?`${rows.length} von maximal 5 Generationen · verschlüsselt mit Age`:'Der erste Lauf startet spätestens fünf Stunden nach Aktivierung.'}catch(e){$('backupNote').textContent=`Backup-Liste nicht verfügbar: ${e.message}`}}refreshBackups();setInterval(refreshBackups,60000);
</script><script src="/full.js"></script><script src="/history.js"></script></body></html>'''.replace( </script><script src="/full.js"></script><script src="/history.js"></script></body></html>'''.replace(
"__MUSIC_ORIGINAL_UI_URL__", MUSIC_ORIGINAL_UI_URL "__MUSIC_ORIGINAL_UI_URL__", MUSIC_ORIGINAL_UI_URL
).replace("__MUSIC_COMMUNITY_UI_URL__", MUSIC_COMMUNITY_UI_URL ).replace("__MUSIC_COMMUNITY_UI_URL__", MUSIC_COMMUNITY_UI_URL
@@ -810,6 +842,62 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) 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: def do_GET(self) -> None:
path = self.path.split("?", 1)[0] path = self.path.split("?", 1)[0]
if path == "/": if path == "/":
@@ -828,6 +916,13 @@ class Handler(BaseHTTPRequestHandler):
range_name = query.get("range", ["24h"])[0] range_name = query.get("range", ["24h"])[0]
body = json.dumps(HISTORY.query(range_name), ensure_ascii=False, separators=(",", ":")).encode() body = json.dumps(HISTORY.query(range_name), ensure_ascii=False, separators=(",", ":")).encode()
self._send(200, body, "application/json; charset=utf-8") 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: else:
self._send(404, b'{"error":"not found"}', "application/json") self._send(404, b'{"error":"not found"}', "application/json")
+36
View File
@@ -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'
+15 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/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 set -Eeuo pipefail
umask 077 umask 077
@@ -69,6 +70,16 @@ done
install -d -m 0700 /etc/mike-ai install -d -m 0700 /etc/mike-ai
rsync -a --delete "$backup_root/etc-mike-ai/" /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() { restore_volume() {
local volume=$1 source=$2 mountpoint local volume=$1 source=$2 mountpoint
[[ -d $source ]] || return 0 [[ -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 mike-ai_router-images "$backup_root/volumes/router-images"
restore_volume portainer_data "$backup_root/volumes/portainer-data" 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 ./manage.sh deploy core
printf 'ATHENA_RESTORE_OK %s\n' "$ARCHIVE" printf 'ATHENA_RESTORE_OK %s\n' "$ARCHIVE"