Add three-scenario disaster recovery
This commit is contained in:
+34
-15
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Executable
+172
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Executable
+78
@@ -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
|
||||||
Executable
+82
@@ -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
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
Executable
+36
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user