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'
+186 -83
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
systemctl start athena-disaster-backup.service
journalctl -u athena-disaster-backup.service --no-pager
restic snapshots --tag athena-disaster
```
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.
## 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 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
``` ```
`--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.
Das Restore verändert weder SSH noch LAN, WireGuard, Kernel, Partitionen oder
Mounts.
## 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"