Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dream-server/extensions/services/dashboard-api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application
COPY main.py config.py models.py security.py gpu.py helpers.py agent_monitor.py ./
COPY main.py config.py models.py security.py gpu.py helpers.py agent_monitor.py user_extensions.py ./
COPY routers/ routers/

# Non-root user
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Extensions portal endpoints."""

import asyncio
import contextlib
import fcntl
import json
Expand Down Expand Up @@ -45,11 +46,14 @@ def _compute_extension_status(ext: dict, services_by_id: dict) -> str:
return "enabled"
return "disabled"

# User-installed extension (file-based status — compose.yaml = enabled)
# User-installed extension — health-based when compose.yaml exists
user_dir = USER_EXTENSIONS_DIR / ext_id
if user_dir.is_dir():
if (user_dir / "compose.yaml").exists():
return "enabled"
svc = services_by_id.get(ext_id)
if svc and svc.status == "healthy":
return "enabled"
return "stopped"
if (user_dir / "compose.yaml.disabled").exists():
return "disabled"

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

# Health-check user extensions so _compute_extension_status can distinguish
# "enabled" (healthy) from "stopped" (unhealthy / not running).
from helpers import check_service_health
from user_extensions import get_user_services_cached

user_svc_configs = get_user_services_cached(USER_EXTENSIONS_DIR)
user_health_tasks = [
check_service_health(sid, cfg) for sid, cfg in user_svc_configs.items()
]
user_health = await asyncio.gather(*user_health_tasks, return_exceptions=True)
for (sid, _), result in zip(user_svc_configs.items(), user_health):
if not isinstance(result, BaseException):
services_by_id[sid] = result

extensions = []
for ext in EXTENSION_CATALOG:
status = _compute_extension_status(ext, services_by_id)
Expand All @@ -394,9 +412,10 @@ async def extensions_catalog(

summary = {
"total": len(extensions),
"installed": sum(1 for e in extensions if e["status"] in ("enabled", "disabled")),
"installed": sum(1 for e in extensions if e["status"] in ("enabled", "disabled", "stopped")),
"enabled": sum(1 for e in extensions if e["status"] == "enabled"),
"disabled": sum(1 for e in extensions if e["status"] == "disabled"),
"stopped": sum(1 for e in extensions if e["status"] == "stopped"),
"not_installed": sum(1 for e in extensions if e["status"] == "not_installed"),
"incompatible": sum(1 for e in extensions if e["status"] == "incompatible"),
}
Expand Down Expand Up @@ -431,10 +450,21 @@ async def extension_detail(
if not ext:
raise HTTPException(status_code=404, detail=f"Extension not found: {service_id}")

from helpers import get_all_services
from helpers import check_service_health, get_all_services
from user_extensions import get_user_services_cached

service_list = await get_all_services()
services_by_id = {s.id: s for s in service_list}

user_svc_configs = get_user_services_cached(USER_EXTENSIONS_DIR)
user_health_tasks = [
check_service_health(sid, cfg) for sid, cfg in user_svc_configs.items()
]
user_health = await asyncio.gather(*user_health_tasks, return_exceptions=True)
for (sid, _), result in zip(user_svc_configs.items(), user_health):
if not isinstance(result, BaseException):
services_by_id[sid] = result

status = _compute_extension_status(ext, services_by_id)
installable = _is_installable(service_id)

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

# Stopped case: compose.yaml exists but container is not running — just start it
if enabled_compose.exists():
raise HTTPException(
status_code=409, detail=f"Extension already enabled: {service_id}",
)
with _extensions_lock():
st = os.lstat(enabled_compose)
if stat.S_ISLNK(st.st_mode):
raise HTTPException(
status_code=400, detail="Compose file is a symlink",
)
_scan_compose_content(enabled_compose)
# Dependencies were satisfied at install time; compose content is re-scanned above
agent_ok = _call_agent("start", service_id)
logger.info("Started stopped extension: %s", service_id)
return {
"id": service_id,
"action": "enabled",
"restart_required": not agent_ok,
"message": (
"Extension started." if agent_ok
else "Extension is enabled. Run 'dream restart' to start."
),
}

if not disabled_compose.exists():
raise HTTPException(
status_code=404, detail=f"Extension has no compose file: {service_id}",
Expand Down
225 changes: 213 additions & 12 deletions dream-server/extensions/services/dashboard-api/tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ def test_user_ext_compose_yaml_healthy(self, test_client, monkeypatch, tmp_path)
assert ext["status"] == "enabled"

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

# No service in health results — svc is None
with patch("helpers.get_all_services", new_callable=AsyncMock,
return_value=[]):
resp = test_client.get(
"/api/extensions/catalog",
headers=test_client.auth_headers,
)
# No service in health results — svc is None → stopped
with patch("user_extensions.get_user_services_cached",
return_value={}):
with patch("helpers.get_all_services", new_callable=AsyncMock,
return_value=[]):
resp = test_client.get(
"/api/extensions/catalog",
headers=test_client.auth_headers,
)

assert resp.status_code == 200
ext = resp.json()["extensions"][0]
assert ext["id"] == "my-ext"
assert ext["status"] == "enabled"
assert ext["status"] == "stopped"

def test_user_ext_compose_yaml_disabled(self, test_client, monkeypatch, tmp_path):
"""User extension with compose.yaml.disabled → disabled."""
Expand Down Expand Up @@ -526,16 +528,20 @@ def test_enable_renames_to_compose_yaml(self, test_client, monkeypatch, tmp_path
assert (user_dir / "my-ext" / "compose.yaml").exists()
assert not (user_dir / "my-ext" / "compose.yaml.disabled").exists()

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

resp = test_client.post(
"/api/extensions/my-ext/enable",
headers=test_client.auth_headers,
)
assert resp.status_code == 409
assert resp.status_code == 200
data = resp.json()
assert data["action"] == "enabled"
# compose.yaml still exists (no rename happened)
assert (user_dir / "my-ext" / "compose.yaml").exists()

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


# --- Extension lifecycle status (stopped / health-based) ---


class TestExtensionLifecycleStatus:

def test_user_extension_enabled_and_healthy(self, test_client, monkeypatch, tmp_path):
"""User extension with compose.yaml + healthy container → enabled."""
user_dir = tmp_path / "user"
ext_dir = user_dir / "my-ext"
ext_dir.mkdir(parents=True)
(ext_dir / "compose.yaml").write_text(_SAFE_COMPOSE)
(ext_dir / "manifest.yaml").write_text(yaml.dump({
"schema_version": "dream.services.v1",
"service": {"id": "my-ext", "name": "My Ext", "port": 8080,
"health": "/health"},
}))

catalog = [_make_catalog_ext("my-ext", "My Extension")]
_patch_extensions_config(monkeypatch, catalog, tmp_path=tmp_path)
monkeypatch.setattr("routers.extensions.USER_EXTENSIONS_DIR", user_dir)

mock_svc = _make_service_status("my-ext", "healthy")
with patch("user_extensions.get_user_services_cached",
return_value={"my-ext": {"host": "my-ext", "port": 8080,
"health": "/health", "name": "My Ext"}}):
with patch("helpers.get_all_services", new_callable=AsyncMock,
return_value=[]):
with patch("helpers.check_service_health", new_callable=AsyncMock,
return_value=mock_svc):
resp = test_client.get(
"/api/extensions/catalog",
headers=test_client.auth_headers,
)

assert resp.status_code == 200
ext = resp.json()["extensions"][0]
assert ext["status"] == "enabled"

def test_user_extension_enabled_but_unhealthy(self, test_client, monkeypatch, tmp_path):
"""User extension with compose.yaml + unhealthy container → stopped."""
user_dir = tmp_path / "user"
ext_dir = user_dir / "my-ext"
ext_dir.mkdir(parents=True)
(ext_dir / "compose.yaml").write_text(_SAFE_COMPOSE)
(ext_dir / "manifest.yaml").write_text(yaml.dump({
"schema_version": "dream.services.v1",
"service": {"id": "my-ext", "name": "My Ext", "port": 8080,
"health": "/health"},
}))

catalog = [_make_catalog_ext("my-ext", "My Extension")]
_patch_extensions_config(monkeypatch, catalog, tmp_path=tmp_path)
monkeypatch.setattr("routers.extensions.USER_EXTENSIONS_DIR", user_dir)

mock_svc = _make_service_status("my-ext", "down")
with patch("user_extensions.get_user_services_cached",
return_value={"my-ext": {"host": "my-ext", "port": 8080,
"health": "/health", "name": "My Ext"}}):
with patch("helpers.get_all_services", new_callable=AsyncMock,
return_value=[]):
with patch("helpers.check_service_health", new_callable=AsyncMock,
return_value=mock_svc):
resp = test_client.get(
"/api/extensions/catalog",
headers=test_client.auth_headers,
)

assert resp.status_code == 200
ext = resp.json()["extensions"][0]
assert ext["status"] == "stopped"

def test_user_extension_disabled_unchanged(self, test_client, monkeypatch, tmp_path):
"""User extension with compose.yaml.disabled → disabled (unchanged)."""
user_dir = tmp_path / "user"
ext_dir = user_dir / "my-ext"
ext_dir.mkdir(parents=True)
(ext_dir / "compose.yaml.disabled").write_text(_SAFE_COMPOSE)

catalog = [_make_catalog_ext("my-ext", "My Extension")]
_patch_extensions_config(monkeypatch, catalog, tmp_path=tmp_path)
monkeypatch.setattr("routers.extensions.USER_EXTENSIONS_DIR", user_dir)

with patch("user_extensions.get_user_services_cached",
return_value={}):
with patch("helpers.get_all_services", new_callable=AsyncMock,
return_value=[]):
resp = test_client.get(
"/api/extensions/catalog",
headers=test_client.auth_headers,
)

assert resp.status_code == 200
ext = resp.json()["extensions"][0]
assert ext["status"] == "disabled"

def test_core_service_status_unchanged(self, test_client, monkeypatch, tmp_path):
"""Core service healthy → enabled, unhealthy → disabled (unchanged)."""
catalog = [_make_catalog_ext("core-svc", "Core Service")]
services = {"core-svc": {"host": "localhost", "port": 8080, "name": "Core"}}
_patch_extensions_config(monkeypatch, catalog, services, tmp_path=tmp_path)

mock_svc = _make_service_status("core-svc", "healthy")
with patch("user_extensions.get_user_services_cached",
return_value={}):
with patch("helpers.get_all_services", new_callable=AsyncMock,
return_value=[mock_svc]):
resp = test_client.get(
"/api/extensions/catalog",
headers=test_client.auth_headers,
)

assert resp.status_code == 200
ext = resp.json()["extensions"][0]
assert ext["status"] == "enabled"

def test_catalog_includes_user_extension_health(self, test_client, monkeypatch, tmp_path):
"""Catalog response includes 'stopped' in summary counts."""
user_dir = tmp_path / "user"
ext_dir = user_dir / "my-ext"
ext_dir.mkdir(parents=True)
(ext_dir / "compose.yaml").write_text(_SAFE_COMPOSE)

catalog = [_make_catalog_ext("my-ext", "My Extension")]
_patch_extensions_config(monkeypatch, catalog, tmp_path=tmp_path)
monkeypatch.setattr("routers.extensions.USER_EXTENSIONS_DIR", user_dir)

# No health data → stopped
with patch("user_extensions.get_user_services_cached",
return_value={}):
with patch("helpers.get_all_services", new_callable=AsyncMock,
return_value=[]):
resp = test_client.get(
"/api/extensions/catalog",
headers=test_client.auth_headers,
)

assert resp.status_code == 200
summary = resp.json()["summary"]
assert summary["stopped"] == 1
assert summary["installed"] == 1

def test_enable_stopped_extension(self, test_client, monkeypatch, tmp_path):
"""Enable when compose.yaml exists (stopped) → starts without rename."""
user_dir = _setup_user_ext(tmp_path, "my-ext", enabled=True)
_patch_mutation_config(monkeypatch, tmp_path, user_dir=user_dir)

resp = test_client.post(
"/api/extensions/my-ext/enable",
headers=test_client.auth_headers,
)

assert resp.status_code == 200
data = resp.json()
assert data["action"] == "enabled"
# compose.yaml should still exist (not renamed)
assert (user_dir / "my-ext" / "compose.yaml").exists()

def test_enable_stopped_rejects_malicious_compose(self, test_client, monkeypatch, tmp_path):
"""Enable stopped ext with malicious compose.yaml → 400."""
bad_compose = "services:\n svc:\n image: test\n privileged: true\n"
user_dir = tmp_path / "user"
user_dir.mkdir(exist_ok=True)
ext_dir = user_dir / "bad-ext"
ext_dir.mkdir(exist_ok=True)
(ext_dir / "compose.yaml").write_text(bad_compose)
_patch_mutation_config(monkeypatch, tmp_path, user_dir=user_dir)

resp = test_client.post(
"/api/extensions/bad-ext/enable",
headers=test_client.auth_headers,
)
assert resp.status_code == 400
assert "privileged" in resp.json()["detail"]


# --- Symlink handling ---


Expand All @@ -1106,6 +1287,26 @@ def test_copytree_safe_skips_symlinks(self, tmp_path):
assert (dst / "real.txt").exists()
assert not (dst / "link.txt").exists()

def test_enable_stopped_rejects_symlinked_compose(
self, test_client, monkeypatch, tmp_path,
):
"""Enable stopped ext rejects a compose.yaml that is a symlink."""
user_dir = tmp_path / "user"
ext_dir = user_dir / "my-ext"
ext_dir.mkdir(parents=True)
# Create a real file and symlink compose.yaml to it
real_compose = tmp_path / "real-compose.yaml"
real_compose.write_text(_SAFE_COMPOSE)
(ext_dir / "compose.yaml").symlink_to(real_compose)
_patch_mutation_config(monkeypatch, tmp_path, user_dir=user_dir)

resp = test_client.post(
"/api/extensions/my-ext/enable",
headers=test_client.auth_headers,
)
assert resp.status_code == 400
assert "symlink" in resp.json()["detail"]

def test_enable_rejects_symlinked_compose(
self, test_client, monkeypatch, tmp_path,
):
Expand Down
Loading
Loading