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
1 change: 1 addition & 0 deletions dream-server/extensions/services/dashboard-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Environment variables (set in `.env`):
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/version` | Yes | Current version + GitHub update check |
| `GET` | `/api/update/readiness` | Yes | Update readiness summary (compatibility + rollback availability) |
| `GET` | `/api/releases/manifest` | No | Recent release history from GitHub |
| `POST` | `/api/update` | Yes | Trigger update actions (`check`, `backup`, `update`) |

Expand Down
105 changes: 105 additions & 0 deletions dream-server/extensions/services/dashboard-api/routers/updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,108 @@ async def run_update():
return {"success": True, "message": "Update started in background. Check logs for progress."}
else:
raise HTTPException(status_code=400, detail=f"Unknown action: {action.action}")


def _resolve_update_script_for_readiness() -> Path | None:
"""Resolve update script path without changing existing update action behavior."""
candidates = (
Path(INSTALL_DIR).parent / "scripts" / "dream-update.sh",
Path(INSTALL_DIR) / "scripts" / "dream-update.sh",
Path(INSTALL_DIR) / "dream-update.sh",
)
for script_path in candidates:
if script_path.exists():
return script_path
return None


def _resolve_compatibility_script() -> Path | None:
candidates = (
Path(INSTALL_DIR).parent / "scripts" / "check-compatibility.sh",
Path(INSTALL_DIR) / "scripts" / "check-compatibility.sh",
)
for script_path in candidates:
if script_path.exists():
return script_path
return None


def _check_compatibility_status() -> dict:
checked_at = datetime.now(timezone.utc).isoformat() + "Z"
script_path = _resolve_compatibility_script()
if script_path is None:
return {
"available": False,
"ok": None,
"checked_at": checked_at,
"details": "check-compatibility.sh not found",
}

try:
result = subprocess.run(
["bash", str(script_path)],
capture_output=True,
text=True,
timeout=30,
check=False,
)
details = ((result.stdout or "") + (result.stderr or "")).strip() or None
return {
"available": True,
"ok": result.returncode == 0,
"checked_at": checked_at,
"details": details[-4000:] if details else None,
}
except subprocess.TimeoutExpired:
return {
"available": True,
"ok": False,
"checked_at": checked_at,
"details": "Compatibility check timed out",
}
except Exception as exc:
logger.exception("Compatibility check failed")
return {
"available": True,
"ok": False,
"checked_at": checked_at,
"details": f"Compatibility check failed: {exc}",
}


def _collect_rollback_state() -> dict:
data_dir = Path(os.environ.get("DREAM_DATA_DIR", "~/.dream-server")).expanduser()
backup_dir = data_dir / "backups"
backups = sorted(
[entry for entry in backup_dir.glob("backup-*") if entry.is_dir()],
key=lambda entry: entry.name,
reverse=True,
)
return {
"backup_dir": str(backup_dir),
"backup_count": len(backups),
"latest_backup": backups[0].name if backups else None,
"available": len(backups) > 0,
}


@router.get("/api/update/readiness", dependencies=[Depends(verify_api_key)])
async def get_update_readiness():
"""Get update readiness including compatibility and rollback availability."""
version_info = await get_version()
if hasattr(version_info, "model_dump"):
version_info = version_info.model_dump()
elif not isinstance(version_info, dict):
version_info = dict(version_info)

update_script = _resolve_update_script_for_readiness()
return {
**version_info,
"update_system": {
"available": update_script is not None,
"script_path": str(update_script) if update_script is not None else None,
},
"compatibility": _check_compatibility_status(),
"rollback": _collect_rollback_state(),
"checked_at": datetime.now(timezone.utc).isoformat() + "Z",
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Tests for update readiness API endpoints and helpers."""

from __future__ import annotations

from pathlib import Path


def test_update_readiness_includes_expected_sections(test_client, monkeypatch):
"""GET /api/update/readiness should return update, compatibility, and rollback sections."""
import routers.updates as updates_router

async def _fake_get_version():
return {
"current": "2.0.0",
"latest": "2.1.0",
"update_available": True,
"changelog_url": "https://example.com/release",
"checked_at": "2026-03-17T00:00:00Z",
}

monkeypatch.setattr(updates_router, "get_version", _fake_get_version)
monkeypatch.setattr(
updates_router,
"_resolve_update_script_for_readiness",
lambda: Path("/tmp/dream-update.sh"),
)
monkeypatch.setattr(
updates_router,
"_check_compatibility_status",
lambda: {
"available": True,
"ok": True,
"checked_at": "2026-03-17T00:00:00Z",
"details": "[PASS] compatibility check complete",
},
)
monkeypatch.setattr(
updates_router,
"_collect_rollback_state",
lambda: {
"backup_dir": "/tmp/backups",
"backup_count": 2,
"latest_backup": "backup-20260317-120000",
"available": True,
},
)

resp = test_client.get("/api/update/readiness", headers=test_client.auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["current"] == "2.0.0"
assert data["latest"] == "2.1.0"
assert data["update_system"]["available"] is True
assert data["compatibility"]["ok"] is True
assert data["rollback"]["backup_count"] == 2


def test_update_readiness_without_update_script(test_client, monkeypatch):
"""GET /api/update/readiness should expose unavailable update script state cleanly."""
import routers.updates as updates_router

async def _fake_get_version():
return {
"current": "2.0.0",
"latest": None,
"update_available": False,
"changelog_url": None,
"checked_at": "2026-03-17T00:00:00Z",
}

monkeypatch.setattr(updates_router, "get_version", _fake_get_version)
monkeypatch.setattr(
updates_router,
"_resolve_update_script_for_readiness",
lambda: None,
)
monkeypatch.setattr(
updates_router,
"_check_compatibility_status",
lambda: {
"available": False,
"ok": None,
"checked_at": "2026-03-17T00:00:00Z",
"details": "check-compatibility.sh not found",
},
)
monkeypatch.setattr(
updates_router,
"_collect_rollback_state",
lambda: {
"backup_dir": "/tmp/backups",
"backup_count": 0,
"latest_backup": None,
"available": False,
},
)

resp = test_client.get("/api/update/readiness", headers=test_client.auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["update_system"]["available"] is False
assert data["compatibility"]["available"] is False
assert data["rollback"]["available"] is False


def test_collect_rollback_state_reads_data_dir(monkeypatch, tmp_path):
"""Rollback helper should enumerate backups from DREAM_DATA_DIR/backups."""
import routers.updates as updates_router

data_dir = tmp_path / "dream-data"
backups_dir = data_dir / "backups"
backups_dir.mkdir(parents=True)
(backups_dir / "backup-20260317-120000").mkdir()
(backups_dir / "backup-20260316-090000").mkdir()

monkeypatch.setenv("DREAM_DATA_DIR", str(data_dir))

state = updates_router._collect_rollback_state()
assert state["available"] is True
assert state["backup_count"] == 2
assert state["latest_backup"] == "backup-20260317-120000"

Loading