Synchronize repository with Athena deployment

This commit is contained in:
Mikei386
2026-09-13 20:01:36 +02:00
parent 040a2df48b
commit fce9900389
60 changed files with 2570 additions and 607 deletions
+2 -2
View File
@@ -71,8 +71,8 @@ class Handler(BaseHTTPRequestHandler):
self._send_json(200, {
"status": "ok",
"ready": True,
"voices": ["claribel"],
"default_voice": "claribel",
"voices": ["alloy"],
"default_voice": "alloy",
"load_errors": [],
"sample_rate": SAMPLE_RATE,
"uptime_seconds": 1.0,
+123
View File
@@ -0,0 +1,123 @@
import importlib.util
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "platform/llama-dashboard/app.py"
class _Response:
status = 202
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self):
return b'{"status":"accepted"}'
class DashboardModeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tempdir = tempfile.TemporaryDirectory()
with patch.dict(os.environ, {
"DASHBOARD_HISTORY_DB": str(Path(cls.tempdir.name) / "history.sqlite3"),
"DASHBOARD_BACKUP_DIR": str(Path(cls.tempdir.name) / "backups"),
"ROUTER_URL": "http://router.test:8081",
"ROUTER_API_KEY": "test-key",
}):
spec = importlib.util.spec_from_file_location("dashboard_app_test", MODULE_PATH)
cls.dashboard = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(cls.dashboard)
cls.backup_dir = Path(cls.tempdir.name) / "backups"
cls.backup_dir.mkdir()
@classmethod
def tearDownClass(cls):
cls.tempdir.cleanup()
def test_separation_mode_is_forwarded_to_router(self):
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
status, body = self.dashboard.change_mode("separation")
self.assertEqual(status, 202)
self.assertEqual(body, {"status": "accepted"})
request = urlopen.call_args.args[0]
self.assertEqual(json.loads(request.data), {"mode": "separation"})
self.assertEqual(request.get_header("Authorization"), "Bearer test-key")
def test_voice_mode_is_forwarded_to_router(self):
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
status, body = self.dashboard.change_mode("voice")
self.assertEqual(status, 202)
self.assertEqual(body, {"status": "accepted"})
request = urlopen.call_args.args[0]
self.assertEqual(json.loads(request.data), {"mode": "voice"})
def test_voice_change_mode_is_forwarded_to_router(self):
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
status, body = self.dashboard.change_mode("voicechange")
self.assertEqual(status, 202)
self.assertEqual(body, {"status": "accepted"})
request = urlopen.call_args.args[0]
self.assertEqual(json.loads(request.data), {"mode": "voicechange"})
def test_applio_mode_is_forwarded_to_router(self):
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
status, body = self.dashboard.change_mode("applio")
self.assertEqual(status, 202)
self.assertEqual(body, {"status": "accepted"})
self.assertEqual(json.loads(urlopen.call_args.args[0].data), {"mode": "applio"})
def test_unknown_mode_is_rejected_without_router_request(self):
with patch.object(self.dashboard.urllib.request, "urlopen") as urlopen:
status, body = self.dashboard.change_mode("unknown")
self.assertEqual(status, 400)
self.assertEqual(body, {"error": "invalid mode"})
urlopen.assert_not_called()
def test_dashboard_uses_one_status_poll_for_all_mode_labels(self):
html = self.dashboard.HTML
self.assertEqual(html.count("fetch('/api/status'"), 1)
self.assertNotIn("refreshVoiceChange", html)
self.assertIn("voicechange:'X-VC Voice Changer'", html)
self.assertIn("applio:'Applio / RVC'", html)
def test_dashboard_offers_both_applio_frontends(self):
html = self.dashboard.HTML
self.assertIn("Original Applio UI", html)
self.assertIn("Mikes Applio UI", html)
self.assertIn("http://192.168.1.212:8011/", html)
self.assertIn("http://192.168.1.212:8012/", html)
def test_dashboard_lists_only_portable_encrypted_backups(self):
valid = self.backup_dir / "athena-portable-2026-09-10T10-00-00Z.tar.zst.age"
valid.write_bytes(b"encrypted")
valid.with_name(valid.name + ".sha256").write_text(
"a" * 64 + " " + valid.name + "\n", encoding="utf-8"
)
(self.backup_dir / "unrelated.txt").write_text("ignore", encoding="utf-8")
backups = self.dashboard.backup_inventory()
self.assertEqual([item["name"] for item in backups], [valid.name])
self.assertEqual(backups[0]["sha256"], "a" * 64)
self.assertTrue(backups[0]["download_url"].startswith("/api/backups/download/"))
if __name__ == "__main__":
unittest.main()
+13 -13
View File
@@ -561,14 +561,14 @@ d=json.load(sys.stdin)
tts=d["tts"]
assert tts["reachable"] is True, tts
assert tts["ready"] is True, tts
assert set(tts["voices"])=={"claribel"}, tts
' && ok "Status: TTS erreichbar, bereit, 2 Stimmen" || bad "Status tts-Section"
assert set(tts["voices"])=={"alloy"}, tts
' && ok "Status: TTS erreichbar und bereit" || bad "Status tts-Section"
# --- 28. TTS: POST /v1/audio/speech (wav) ---------------------------------------------------------------
echo "== Test 28: POST /v1/audio/speech (wav)"
CODE=$(curl -s -o /tmp/tts28.wav -w "%{http_code}" -D /tmp/hdr28.txt \
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
-d '{"model":"xtts-v2","input":"Hallo Welt","voice":"claribel","response_format":"wav"}')
-d '{"model":"qwen3-tts","input":"Hallo Welt","voice":"alloy","response_format":"wav"}')
CTYPE=$(grep -i content-type /tmp/hdr28.txt | tr -d "\r")
[ "$CODE" = "200" ] && [ -s /tmp/tts28.wav ] && echo "$CTYPE" | grep -qi "audio/wav" \
&& ok "TTS wav (200, $CTYPE, $(stat -f%z /tmp/tts28.wav 2>/dev/null || stat -c%s /tmp/tts28.wav) Bytes)" \
@@ -578,7 +578,7 @@ CTYPE=$(grep -i content-type /tmp/hdr28.txt | tr -d "\r")
echo "== Test 29: POST /v1/audio/speech (mp3, Default)"
CODE=$(curl -s -o /tmp/tts29.mp3 -w "%{http_code}" -D /tmp/hdr29.txt \
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
-d '{"input":"Guten Tag","voice":"claribel"}')
-d '{"input":"Guten Tag","voice":"alloy"}')
CTYPE=$(grep -i content-type /tmp/hdr29.txt | tr -d "\r")
[ "$CODE" = "200" ] && [ -s /tmp/tts29.mp3 ] && echo "$CTYPE" | grep -qi "audio/mpeg" \
&& ok "TTS mp3 (200, $CTYPE)" || bad "TTS mp3 (Code $CODE, $CTYPE)"
@@ -586,7 +586,7 @@ CTYPE=$(grep -i content-type /tmp/hdr29.txt | tr -d "\r")
# --- 30. TTS: Validierung --------------------------------------------------------------------------------
echo "== Test 30: TTS-Validierung"
CODE=$(curl -s -o /tmp/err30a.json -w "%{http_code}" "$BASE/v1/audio/speech" \
-H "Content-Type: application/json" -d '{"voice":"claribel"}')
-H "Content-Type: application/json" -d '{"voice":"alloy"}')
cat /tmp/err30a.json; echo
[ "$CODE" = "400" ] && ok "400 bei fehlendem input" || bad "erwartet 400, bekam $CODE"
@@ -608,7 +608,7 @@ cat /tmp/err30d.json; echo
# --- 31. TTS: Worker-Fehler → 503 ------------------------------------------------------------------------
echo "== Test 31: TTS-Worker-Fehler → 503"
CODE=$(curl -s -o /tmp/err31.json -w "%{http_code}" "$BASE/v1/audio/speech" \
-H "Content-Type: application/json" -d '{"input":"FAIL","voice":"claribel"}')
-H "Content-Type: application/json" -d '{"input":"FAIL","voice":"alloy"}')
cat /tmp/err31.json; echo
[ "$CODE" = "503" ] && ok "503 bei TTS-Worker-Fehler" || bad "erwartet 503, bekam $CODE"
@@ -617,7 +617,7 @@ echo "== Test 32: TTS-Worker down → 503"
kill "$TTS_PID" 2>/dev/null || true
sleep 0.5
CODE=$(curl -s -o /tmp/err32.json -w "%{http_code}" "$BASE/v1/audio/speech" \
-H "Content-Type: application/json" -d '{"input":"Hallo","voice":"claribel"}')
-H "Content-Type: application/json" -d '{"input":"Hallo","voice":"alloy"}')
cat /tmp/err32.json; echo
[ "$CODE" = "503" ] && ok "503 bei downem TTS-Worker" || bad "erwartet 503, bekam $CODE"
RESP=$(curl -sf "$BASE/status")
@@ -634,7 +634,7 @@ MOCK_TTS_PORT="$TTS_PORT" MOCK_TTS_DELAY=0.1 \
TTS_PID=$!
sleep 0.5
CODE=$(curl -s -o /tmp/tts33.wav -w "%{http_code}" "$BASE/v1/audio/speech" \
-H "Content-Type: application/json" -d '{"input":"Wieder da","voice":"claribel","response_format":"wav"}')
-H "Content-Type: application/json" -d '{"input":"Wieder da","voice":"alloy","response_format":"wav"}')
[ "$CODE" = "200" ] && [ -s /tmp/tts33.wav ] \
&& ok "TTS nach Neustart wieder verfügbar" || bad "TTS-Recovery (Code $CODE)"
@@ -727,8 +727,8 @@ import json,sys
d=json.load(sys.stdin)
ids={m["id"] for m in d["data"]}
assert "whisper-1" in ids, ids
assert "xtts-v2" in ids, ids
' && ok "Audio-Modelle: whisper-1 + xtts-v2" || bad "Audio-Modelle"
assert "qwen3-tts" in ids, ids
' && ok "Audio-Modelle: whisper-1 + qwen3-tts" || bad "Audio-Modelle"
# --- 41. /v1/audio/voices ------------------------------------------------------------------------------------------
echo "== Test 41: GET /v1/audio/voices"
@@ -738,8 +738,8 @@ echo "$RESP" | python3 -c '
import json,sys
d=json.load(sys.stdin)
ids={v["id"] for v in d["data"]}
assert "claribel" in ids, ids
' && ok "Audio-Voices: claribel" || bad "Audio-Voices"
assert "alloy" in ids, ids
' && ok "Audio-Voices: alloy" || bad "Audio-Voices"
# --- 42. STT + Qwen parallel ----------------------------------------------------------------------------------------
echo "== Test 42: STT + Qwen parallel"
@@ -770,7 +770,7 @@ sleep 0.2
# TTS-Request
CODE=$(curl -s -o /tmp/tts43.mp3 -w "%{http_code}" \
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
-d '{"input":"Hallo","voice":"claribel"}')
-d '{"input":"Hallo","voice":"alloy"}')
wait $STT_PID43
[ "$CODE" = "200" ] && [ -s /tmp/tts43.mp3 ] \
&& ok "STT + TTS parallel (beide 200)" || bad "STT + TTS parallel (TTS Code $CODE)"
+73 -4
View File
@@ -26,7 +26,48 @@ def image_item(state="exited"):
"Labels": {controller.IMAGE_LABEL_KEY: controller.IMAGE_WORKER}}
def restore_item(state="exited"):
return {"Id": "id-restore", "State": state,
"Labels": {controller.IMAGE_LABEL_KEY: controller.RESTORE_WORKER}}
def tts_item(state="running"):
return {"Id": "id-tts", "State": state,
"Labels": {controller.TTS_LABEL_KEY: controller.TTS_WORKER}}
def music_item(state="exited"):
return {"Id": "id-music", "State": state,
"Labels": {controller.MUSIC_LABEL_KEY: "acestep"}}
class ProfileControllerTests(unittest.TestCase):
def test_music_start_exclusively_stops_gpu_workers(self):
profiles = {name: item(name) for name in controller.ALLOWED}
profiles["ultra"] = item("ultra", "running")
calls = []
def request(method, path):
calls.append((method, path))
return 204, b""
with patch.object(controller, "MUSIC_WORKER", "acestep"), \
patch.object(controller, "containers", return_value=profiles), \
patch.object(controller, "music_container", return_value=music_item()), \
patch.object(controller, "image_containers",
return_value=[image_item("running")]), \
patch.object(controller, "tts_container", return_value=tts_item()), \
patch.object(controller, "docker_request", side_effect=request):
result = controller.set_music_worker(True)
self.assertEqual(result, {"music_worker": "acestep", "state": "running"})
self.assertEqual(calls, [
("POST", "/containers/id-ultra/stop?t=120"),
("POST", "/containers/id-flux/stop?t=20"),
("POST", "/containers/id-tts/stop?t=30"),
("POST", "/containers/id-music/start"),
])
def test_rejects_unknown_profile_before_docker_call(self):
with patch.object(controller, "docker_request") as request:
with self.assertRaises(ValueError):
@@ -43,7 +84,8 @@ class ProfileControllerTests(unittest.TestCase):
return 204, b""
with patch.object(controller, "containers", return_value=profiles), \
patch.object(controller, "image_container", return_value=image_item()), \
patch.object(controller, "image_containers", return_value=[image_item()]), \
patch.object(controller, "tts_container", return_value=tts_item()), \
patch.object(controller, "docker_request", side_effect=request):
result = controller.activate("medium")
@@ -56,7 +98,8 @@ class ProfileControllerTests(unittest.TestCase):
def test_fails_if_profile_container_is_missing(self):
profiles = {name: item(name) for name in controller.ALLOWED[:-1]}
with patch.object(controller, "containers", return_value=profiles), \
patch.object(controller, "image_container", return_value=image_item()):
patch.object(controller, "image_containers", return_value=[image_item()]), \
patch.object(controller, "tts_container", return_value=tts_item()):
with self.assertRaisesRegex(RuntimeError, "missing"):
controller.activate("fast")
@@ -71,13 +114,38 @@ class ProfileControllerTests(unittest.TestCase):
with patch.object(controller, "containers", return_value=profiles), \
patch.object(controller, "image_container", return_value=image_item()), \
patch.object(controller, "image_containers",
return_value=[image_item(), restore_item()]), \
patch.object(controller, "tts_container", return_value=tts_item()), \
patch.object(controller, "docker_request", side_effect=request):
controller.set_image_worker(True)
self.assertEqual(calls, [
("POST", "/containers/id-medium/stop?t=120"),
("POST", "/containers/id-tts/stop?t=30"),
("POST", "/containers/id-flux/start"),
])
def test_restore_start_stops_flux_and_starts_restore(self):
profiles = {name: item(name) for name in controller.ALLOWED}
calls = []
def request(method, path):
calls.append((method, path))
return 204, b""
with patch.object(controller, "containers", return_value=profiles), \
patch.object(controller, "image_container", return_value=restore_item()), \
patch.object(controller, "image_containers",
return_value=[image_item("running"), restore_item()]), \
patch.object(controller, "tts_container", return_value=tts_item()), \
patch.object(controller, "docker_request", side_effect=request):
controller.set_image_worker(True, controller.RESTORE_WORKER)
self.assertEqual(calls, [
("POST", "/containers/id-tts/stop?t=30"),
("POST", "/containers/id-flux/stop?t=20"),
("POST", "/containers/id-restore/start"),
])
def test_profile_activation_stops_image_worker_first(self):
profiles = {name: item(name) for name in controller.ALLOWED}
calls = []
@@ -87,8 +155,9 @@ class ProfileControllerTests(unittest.TestCase):
return 204, b""
with patch.object(controller, "containers", return_value=profiles), \
patch.object(controller, "image_container",
return_value=image_item("running")), \
patch.object(controller, "image_containers",
return_value=[image_item("running"), restore_item()]), \
patch.object(controller, "tts_container", return_value=tts_item()), \
patch.object(controller, "docker_request", side_effect=request):
controller.activate("fast")
self.assertEqual(calls, [
+34 -8
View File
@@ -82,19 +82,45 @@ class RecoveryScriptTests(unittest.TestCase):
for profile in ("fast", "medium", "large", "ultra", "uncensored"):
self.assertIn(f"llama-{profile}", installer)
def test_gateway_consumers_are_stopped_before_gateway_recreation(self) -> None:
def test_gateway_consumers_do_not_require_rebinding(self) -> None:
manager = (ROOT / "manage.sh").read_text(encoding="utf-8")
stop = 'stop llama-dashboard portainer'
deploy = 'up -d --build'
self.assertIn(stop, manager)
self.assertLess(manager.index(stop), manager.index(deploy))
installer = (ROOT / "install.sh").read_text(encoding="utf-8")
self.assertNotIn('stop llama-dashboard portainer', manager)
self.assertNotIn('stop llama-dashboard portainer', installer)
self.assertNotIn('force-recreate llama-dashboard portainer', manager)
def test_gateway_owns_shared_ui_ports_and_portainer_backup(self) -> None:
def test_gateway_proxies_stable_ui_services_and_portainer_backup(self) -> None:
compose = (ROOT / "compose.yaml").read_text(encoding="utf-8")
self.assertIn('"8099:8099"', compose)
self.assertIn('"9443:9443"', compose)
gateway = (ROOT / "platform/docker/wireguard-gateway/entrypoint.sh").read_text(
encoding="utf-8"
)
self.assertNotIn('network_mode: "service:wireguard-gateway"', compose)
self.assertNotIn('"8099:8099"', compose)
self.assertNotIn('"9443:9443"', compose)
self.assertIn('start_proxy 8099 llama-dashboard:8099', gateway)
self.assertIn('start_proxy 9443 portainer:9443', gateway)
self.assertIn('portainer-data:/backup/volumes/portainer-data:ro', compose)
def test_disaster_recovery_covers_all_three_scenarios_without_formatting(self) -> None:
script = (ROOT / "disaster-recovery.sh").read_text(encoding="utf-8")
for scenario in ("system", "data", "all"):
self.assertIn(scenario, script)
self.assertIn("mountpoint -q /data", script)
self.assertIn("--portable", script)
for destructive in ("mkfs", "fdisk", "parted", "reboot", "shutdown"):
self.assertNotIn(f"{destructive} ", script)
def test_backup_layers_include_code_and_irreplaceable_data(self) -> None:
compose = (ROOT / "compose.yaml").read_text(encoding="utf-8")
export = (ROOT / "platform/backup/athena-export-backup").read_text(
encoding="utf-8"
)
self.assertIn("/opt/mike-ai:/backup/opt-mike-ai:ro", compose)
self.assertIn("/data/voice/applio/logs", export)
self.assertIn("/data/voice/applio/datasets", export)
self.assertIn("ATHENA_EXPORT_KEEP:-5", export)
self.assertNotIn("add_path /data/models", export)
if __name__ == "__main__":
unittest.main()