diff --git a/dream-server/extensions/services/dashboard-api/README.md b/dream-server/extensions/services/dashboard-api/README.md index 595021be..ffe98ba0 100644 --- a/dream-server/extensions/services/dashboard-api/README.md +++ b/dream-server/extensions/services/dashboard-api/README.md @@ -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`) | diff --git a/dream-server/extensions/services/dashboard-api/routers/updates.py b/dream-server/extensions/services/dashboard-api/routers/updates.py index fb4309e4..2f420561 100644 --- a/dream-server/extensions/services/dashboard-api/routers/updates.py +++ b/dream-server/extensions/services/dashboard-api/routers/updates.py @@ -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", + } diff --git a/dream-server/extensions/services/dashboard-api/tests/test_updates_readiness.py b/dream-server/extensions/services/dashboard-api/tests/test_updates_readiness.py new file mode 100644 index 00000000..47084373 --- /dev/null +++ b/dream-server/extensions/services/dashboard-api/tests/test_updates_readiness.py @@ -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" +