Synchronize repository with Athena deployment
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user