Skip to content

Commit d9d96ec

Browse files
yasinBursaliclaude
andcommitted
fix(extensions): health-based status for user extensions + restart for stopped services
User extensions now get real health checking instead of file-based status. "stopped" status (red badge) replaces the misleading "enabled" for crashed containers. Start button allows restarting stopped extensions without the disable-enable workaround. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 71ded15 commit d9d96ec

File tree

5 files changed

+644
-24
lines changed

5 files changed

+644
-24
lines changed

dream-server/extensions/services/dashboard-api/routers/extensions.py

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Extensions portal endpoints."""
22

3+
import asyncio
34
import contextlib
45
import fcntl
56
import json
@@ -45,11 +46,14 @@ def _compute_extension_status(ext: dict, services_by_id: dict) -> str:
4546
return "enabled"
4647
return "disabled"
4748

48-
# User-installed extension (file-based status — compose.yaml = enabled)
49+
# User-installed extension — health-based when compose.yaml exists
4950
user_dir = USER_EXTENSIONS_DIR / ext_id
5051
if user_dir.is_dir():
5152
if (user_dir / "compose.yaml").exists():
52-
return "enabled"
53+
svc = services_by_id.get(ext_id)
54+
if svc and svc.status == "healthy":
55+
return "enabled"
56+
return "stopped"
5357
if (user_dir / "compose.yaml.disabled").exists():
5458
return "disabled"
5559

@@ -374,6 +378,20 @@ async def extensions_catalog(
374378
service_list = await get_all_services()
375379
services_by_id = {s.id: s for s in service_list}
376380

381+
# Health-check user extensions so _compute_extension_status can distinguish
382+
# "enabled" (healthy) from "stopped" (unhealthy / not running).
383+
from helpers import check_service_health
384+
from user_extensions import get_user_services_cached
385+
386+
user_svc_configs = get_user_services_cached(USER_EXTENSIONS_DIR)
387+
user_health_tasks = [
388+
check_service_health(sid, cfg) for sid, cfg in user_svc_configs.items()
389+
]
390+
user_health = await asyncio.gather(*user_health_tasks, return_exceptions=True)
391+
for (sid, _), result in zip(user_svc_configs.items(), user_health):
392+
if not isinstance(result, BaseException):
393+
services_by_id[sid] = result
394+
377395
extensions = []
378396
for ext in EXTENSION_CATALOG:
379397
status = _compute_extension_status(ext, services_by_id)
@@ -394,9 +412,10 @@ async def extensions_catalog(
394412

395413
summary = {
396414
"total": len(extensions),
397-
"installed": sum(1 for e in extensions if e["status"] in ("enabled", "disabled")),
415+
"installed": sum(1 for e in extensions if e["status"] in ("enabled", "disabled", "stopped")),
398416
"enabled": sum(1 for e in extensions if e["status"] == "enabled"),
399417
"disabled": sum(1 for e in extensions if e["status"] == "disabled"),
418+
"stopped": sum(1 for e in extensions if e["status"] == "stopped"),
400419
"not_installed": sum(1 for e in extensions if e["status"] == "not_installed"),
401420
"incompatible": sum(1 for e in extensions if e["status"] == "incompatible"),
402421
}
@@ -431,10 +450,21 @@ async def extension_detail(
431450
if not ext:
432451
raise HTTPException(status_code=404, detail=f"Extension not found: {service_id}")
433452

434-
from helpers import get_all_services
453+
from helpers import check_service_health, get_all_services
454+
from user_extensions import get_user_services_cached
435455

436456
service_list = await get_all_services()
437457
services_by_id = {s.id: s for s in service_list}
458+
459+
user_svc_configs = get_user_services_cached(USER_EXTENSIONS_DIR)
460+
user_health_tasks = [
461+
check_service_health(sid, cfg) for sid, cfg in user_svc_configs.items()
462+
]
463+
user_health = await asyncio.gather(*user_health_tasks, return_exceptions=True)
464+
for (sid, _), result in zip(user_svc_configs.items(), user_health):
465+
if not isinstance(result, BaseException):
466+
services_by_id[sid] = result
467+
438468
status = _compute_extension_status(ext, services_by_id)
439469
installable = _is_installable(service_id)
440470

@@ -615,10 +645,28 @@ def enable_extension(service_id: str, api_key: str = Depends(verify_api_key)):
615645
disabled_compose = ext_dir / "compose.yaml.disabled"
616646
enabled_compose = ext_dir / "compose.yaml"
617647

648+
# Stopped case: compose.yaml exists but container is not running — just start it
618649
if enabled_compose.exists():
619-
raise HTTPException(
620-
status_code=409, detail=f"Extension already enabled: {service_id}",
621-
)
650+
with _extensions_lock():
651+
st = os.lstat(enabled_compose)
652+
if stat.S_ISLNK(st.st_mode):
653+
raise HTTPException(
654+
status_code=400, detail="Compose file is a symlink",
655+
)
656+
_scan_compose_content(enabled_compose)
657+
# Dependencies were satisfied at install time; compose content is re-scanned above
658+
agent_ok = _call_agent("start", service_id)
659+
logger.info("Started stopped extension: %s", service_id)
660+
return {
661+
"id": service_id,
662+
"action": "enabled",
663+
"restart_required": not agent_ok,
664+
"message": (
665+
"Extension started." if agent_ok
666+
else "Extension is enabled. Run 'dream restart' to start."
667+
),
668+
}
669+
622670
if not disabled_compose.exists():
623671
raise HTTPException(
624672
status_code=404, detail=f"Extension has no compose file: {service_id}",

dream-server/extensions/services/dashboard-api/tests/test_extensions.py

Lines changed: 215 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Tests for extensions portal endpoints."""
22

3+
import os
34
from pathlib import Path
4-
from unittest.mock import AsyncMock, patch
5+
from unittest.mock import AsyncMock, MagicMock, patch
56

67
import yaml
78
from models import ServiceStatus
@@ -274,7 +275,7 @@ def test_user_ext_compose_yaml_healthy(self, test_client, monkeypatch, tmp_path)
274275
assert ext["status"] == "enabled"
275276

276277
def test_user_ext_compose_yaml_no_service(self, test_client, monkeypatch, tmp_path):
277-
"""User extension with compose.yaml but no running container → enabled (file-based status)."""
278+
"""User extension with compose.yaml but no running container → stopped."""
278279
user_dir = tmp_path / "user"
279280
ext_dir = user_dir / "my-ext"
280281
ext_dir.mkdir(parents=True)
@@ -284,18 +285,20 @@ def test_user_ext_compose_yaml_no_service(self, test_client, monkeypatch, tmp_pa
284285
_patch_extensions_config(monkeypatch, catalog, tmp_path=tmp_path)
285286
monkeypatch.setattr("routers.extensions.USER_EXTENSIONS_DIR", user_dir)
286287

287-
# No service in health results — svc is None
288-
with patch("helpers.get_all_services", new_callable=AsyncMock,
289-
return_value=[]):
290-
resp = test_client.get(
291-
"/api/extensions/catalog",
292-
headers=test_client.auth_headers,
293-
)
288+
# No service in health results — svc is None → stopped
289+
with patch("user_extensions.get_user_services_cached",
290+
return_value={}):
291+
with patch("helpers.get_all_services", new_callable=AsyncMock,
292+
return_value=[]):
293+
resp = test_client.get(
294+
"/api/extensions/catalog",
295+
headers=test_client.auth_headers,
296+
)
294297

295298
assert resp.status_code == 200
296299
ext = resp.json()["extensions"][0]
297300
assert ext["id"] == "my-ext"
298-
assert ext["status"] == "enabled"
301+
assert ext["status"] == "stopped"
299302

300303
def test_user_ext_compose_yaml_disabled(self, test_client, monkeypatch, tmp_path):
301304
"""User extension with compose.yaml.disabled → disabled."""
@@ -526,16 +529,20 @@ def test_enable_renames_to_compose_yaml(self, test_client, monkeypatch, tmp_path
526529
assert (user_dir / "my-ext" / "compose.yaml").exists()
527530
assert not (user_dir / "my-ext" / "compose.yaml.disabled").exists()
528531

529-
def test_enable_already_enabled_409(self, test_client, monkeypatch, tmp_path):
530-
"""409 when extension is already enabled."""
532+
def test_enable_stopped_starts_without_rename(self, test_client, monkeypatch, tmp_path):
533+
"""Enable when compose.yaml exists (stopped) → starts without rename."""
531534
user_dir = _setup_user_ext(tmp_path, "my-ext", enabled=True)
532535
_patch_mutation_config(monkeypatch, tmp_path, user_dir=user_dir)
533536

534537
resp = test_client.post(
535538
"/api/extensions/my-ext/enable",
536539
headers=test_client.auth_headers,
537540
)
538-
assert resp.status_code == 409
541+
assert resp.status_code == 200
542+
data = resp.json()
543+
assert data["action"] == "enabled"
544+
# compose.yaml still exists (no rename happened)
545+
assert (user_dir / "my-ext" / "compose.yaml").exists()
539546

540547
def test_enable_allows_core_service_dependency(self, test_client, monkeypatch, tmp_path):
541548
"""Enable succeeds when depends_on includes a core service."""
@@ -1086,6 +1093,181 @@ def test_install_rejects_oversized_extension(
10861093
assert "50MB" in resp.json()["detail"]
10871094

10881095

1096+
# --- Extension lifecycle status (stopped / health-based) ---
1097+
1098+
1099+
class TestExtensionLifecycleStatus:
1100+
1101+
def test_user_extension_enabled_and_healthy(self, test_client, monkeypatch, tmp_path):
1102+
"""User extension with compose.yaml + healthy container → enabled."""
1103+
user_dir = tmp_path / "user"
1104+
ext_dir = user_dir / "my-ext"
1105+
ext_dir.mkdir(parents=True)
1106+
(ext_dir / "compose.yaml").write_text(_SAFE_COMPOSE)
1107+
(ext_dir / "manifest.yaml").write_text(yaml.dump({
1108+
"schema_version": "dream.services.v1",
1109+
"service": {"id": "my-ext", "name": "My Ext", "port": 8080,
1110+
"health": "/health"},
1111+
}))
1112+
1113+
catalog = [_make_catalog_ext("my-ext", "My Extension")]
1114+
_patch_extensions_config(monkeypatch, catalog, tmp_path=tmp_path)
1115+
monkeypatch.setattr("routers.extensions.USER_EXTENSIONS_DIR", user_dir)
1116+
1117+
mock_svc = _make_service_status("my-ext", "healthy")
1118+
with patch("user_extensions.get_user_services_cached",
1119+
return_value={"my-ext": {"host": "my-ext", "port": 8080,
1120+
"health": "/health", "name": "My Ext"}}):
1121+
with patch("helpers.get_all_services", new_callable=AsyncMock,
1122+
return_value=[]):
1123+
with patch("helpers.check_service_health", new_callable=AsyncMock,
1124+
return_value=mock_svc):
1125+
resp = test_client.get(
1126+
"/api/extensions/catalog",
1127+
headers=test_client.auth_headers,
1128+
)
1129+
1130+
assert resp.status_code == 200
1131+
ext = resp.json()["extensions"][0]
1132+
assert ext["status"] == "enabled"
1133+
1134+
def test_user_extension_enabled_but_unhealthy(self, test_client, monkeypatch, tmp_path):
1135+
"""User extension with compose.yaml + unhealthy container → stopped."""
1136+
user_dir = tmp_path / "user"
1137+
ext_dir = user_dir / "my-ext"
1138+
ext_dir.mkdir(parents=True)
1139+
(ext_dir / "compose.yaml").write_text(_SAFE_COMPOSE)
1140+
(ext_dir / "manifest.yaml").write_text(yaml.dump({
1141+
"schema_version": "dream.services.v1",
1142+
"service": {"id": "my-ext", "name": "My Ext", "port": 8080,
1143+
"health": "/health"},
1144+
}))
1145+
1146+
catalog = [_make_catalog_ext("my-ext", "My Extension")]
1147+
_patch_extensions_config(monkeypatch, catalog, tmp_path=tmp_path)
1148+
monkeypatch.setattr("routers.extensions.USER_EXTENSIONS_DIR", user_dir)
1149+
1150+
mock_svc = _make_service_status("my-ext", "down")
1151+
with patch("user_extensions.get_user_services_cached",
1152+
return_value={"my-ext": {"host": "my-ext", "port": 8080,
1153+
"health": "/health", "name": "My Ext"}}):
1154+
with patch("helpers.get_all_services", new_callable=AsyncMock,
1155+
return_value=[]):
1156+
with patch("helpers.check_service_health", new_callable=AsyncMock,
1157+
return_value=mock_svc):
1158+
resp = test_client.get(
1159+
"/api/extensions/catalog",
1160+
headers=test_client.auth_headers,
1161+
)
1162+
1163+
assert resp.status_code == 200
1164+
ext = resp.json()["extensions"][0]
1165+
assert ext["status"] == "stopped"
1166+
1167+
def test_user_extension_disabled_unchanged(self, test_client, monkeypatch, tmp_path):
1168+
"""User extension with compose.yaml.disabled → disabled (unchanged)."""
1169+
user_dir = tmp_path / "user"
1170+
ext_dir = user_dir / "my-ext"
1171+
ext_dir.mkdir(parents=True)
1172+
(ext_dir / "compose.yaml.disabled").write_text(_SAFE_COMPOSE)
1173+
1174+
catalog = [_make_catalog_ext("my-ext", "My Extension")]
1175+
_patch_extensions_config(monkeypatch, catalog, tmp_path=tmp_path)
1176+
monkeypatch.setattr("routers.extensions.USER_EXTENSIONS_DIR", user_dir)
1177+
1178+
with patch("user_extensions.get_user_services_cached",
1179+
return_value={}):
1180+
with patch("helpers.get_all_services", new_callable=AsyncMock,
1181+
return_value=[]):
1182+
resp = test_client.get(
1183+
"/api/extensions/catalog",
1184+
headers=test_client.auth_headers,
1185+
)
1186+
1187+
assert resp.status_code == 200
1188+
ext = resp.json()["extensions"][0]
1189+
assert ext["status"] == "disabled"
1190+
1191+
def test_core_service_status_unchanged(self, test_client, monkeypatch, tmp_path):
1192+
"""Core service healthy → enabled, unhealthy → disabled (unchanged)."""
1193+
catalog = [_make_catalog_ext("core-svc", "Core Service")]
1194+
services = {"core-svc": {"host": "localhost", "port": 8080, "name": "Core"}}
1195+
_patch_extensions_config(monkeypatch, catalog, services, tmp_path=tmp_path)
1196+
1197+
mock_svc = _make_service_status("core-svc", "healthy")
1198+
with patch("user_extensions.get_user_services_cached",
1199+
return_value={}):
1200+
with patch("helpers.get_all_services", new_callable=AsyncMock,
1201+
return_value=[mock_svc]):
1202+
resp = test_client.get(
1203+
"/api/extensions/catalog",
1204+
headers=test_client.auth_headers,
1205+
)
1206+
1207+
assert resp.status_code == 200
1208+
ext = resp.json()["extensions"][0]
1209+
assert ext["status"] == "enabled"
1210+
1211+
def test_catalog_includes_user_extension_health(self, test_client, monkeypatch, tmp_path):
1212+
"""Catalog response includes 'stopped' in summary counts."""
1213+
user_dir = tmp_path / "user"
1214+
ext_dir = user_dir / "my-ext"
1215+
ext_dir.mkdir(parents=True)
1216+
(ext_dir / "compose.yaml").write_text(_SAFE_COMPOSE)
1217+
1218+
catalog = [_make_catalog_ext("my-ext", "My Extension")]
1219+
_patch_extensions_config(monkeypatch, catalog, tmp_path=tmp_path)
1220+
monkeypatch.setattr("routers.extensions.USER_EXTENSIONS_DIR", user_dir)
1221+
1222+
# No health data → stopped
1223+
with patch("user_extensions.get_user_services_cached",
1224+
return_value={}):
1225+
with patch("helpers.get_all_services", new_callable=AsyncMock,
1226+
return_value=[]):
1227+
resp = test_client.get(
1228+
"/api/extensions/catalog",
1229+
headers=test_client.auth_headers,
1230+
)
1231+
1232+
assert resp.status_code == 200
1233+
summary = resp.json()["summary"]
1234+
assert summary["stopped"] == 1
1235+
assert summary["installed"] == 1
1236+
1237+
def test_enable_stopped_extension(self, test_client, monkeypatch, tmp_path):
1238+
"""Enable when compose.yaml exists (stopped) → starts without rename."""
1239+
user_dir = _setup_user_ext(tmp_path, "my-ext", enabled=True)
1240+
_patch_mutation_config(monkeypatch, tmp_path, user_dir=user_dir)
1241+
1242+
resp = test_client.post(
1243+
"/api/extensions/my-ext/enable",
1244+
headers=test_client.auth_headers,
1245+
)
1246+
1247+
assert resp.status_code == 200
1248+
data = resp.json()
1249+
assert data["action"] == "enabled"
1250+
# compose.yaml should still exist (not renamed)
1251+
assert (user_dir / "my-ext" / "compose.yaml").exists()
1252+
1253+
def test_enable_stopped_rejects_malicious_compose(self, test_client, monkeypatch, tmp_path):
1254+
"""Enable stopped ext with malicious compose.yaml → 400."""
1255+
bad_compose = "services:\n svc:\n image: test\n privileged: true\n"
1256+
user_dir = tmp_path / "user"
1257+
user_dir.mkdir(exist_ok=True)
1258+
ext_dir = user_dir / "bad-ext"
1259+
ext_dir.mkdir(exist_ok=True)
1260+
(ext_dir / "compose.yaml").write_text(bad_compose)
1261+
_patch_mutation_config(monkeypatch, tmp_path, user_dir=user_dir)
1262+
1263+
resp = test_client.post(
1264+
"/api/extensions/bad-ext/enable",
1265+
headers=test_client.auth_headers,
1266+
)
1267+
assert resp.status_code == 400
1268+
assert "privileged" in resp.json()["detail"]
1269+
1270+
10891271
# --- Symlink handling ---
10901272

10911273

@@ -1106,6 +1288,26 @@ def test_copytree_safe_skips_symlinks(self, tmp_path):
11061288
assert (dst / "real.txt").exists()
11071289
assert not (dst / "link.txt").exists()
11081290

1291+
def test_enable_stopped_rejects_symlinked_compose(
1292+
self, test_client, monkeypatch, tmp_path,
1293+
):
1294+
"""Enable stopped ext rejects a compose.yaml that is a symlink."""
1295+
user_dir = tmp_path / "user"
1296+
ext_dir = user_dir / "my-ext"
1297+
ext_dir.mkdir(parents=True)
1298+
# Create a real file and symlink compose.yaml to it
1299+
real_compose = tmp_path / "real-compose.yaml"
1300+
real_compose.write_text(_SAFE_COMPOSE)
1301+
(ext_dir / "compose.yaml").symlink_to(real_compose)
1302+
_patch_mutation_config(monkeypatch, tmp_path, user_dir=user_dir)
1303+
1304+
resp = test_client.post(
1305+
"/api/extensions/my-ext/enable",
1306+
headers=test_client.auth_headers,
1307+
)
1308+
assert resp.status_code == 400
1309+
assert "symlink" in resp.json()["detail"]
1310+
11091311
def test_enable_rejects_symlinked_compose(
11101312
self, test_client, monkeypatch, tmp_path,
11111313
):

0 commit comments

Comments
 (0)