Build optimization: pre-built PM3 binaries, ARM64 CI, base image caching
Replace PM3 compile-from-source in pi-gen with pre-built tarball extraction (saves 43-58 min). Merge stagePM3 into stageDangerousPi as 02-pm3-install substage, renumber all subsequent substages. Switch CI PM3 build to native ARM64 runner (ubuntu-24.04-arm64) eliminating QEMU overhead. Add weekly base-image workflow for pre-baking stages 0-2. Support PM3_TARBALL, BASE_IMAGE, and APT_PROXY env vars in build-image.sh. Also includes prior Phase 5 work: theme system, design system integration, component update system, OS updates, CI build pipeline, and test results. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,13 +3,17 @@
|
||||
Refactored to use services for business logic.
|
||||
Session management uses PM3Service, system operations use SystemService.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from .. import config
|
||||
from ..services.container import container
|
||||
from ..managers.ups_manager import get_ups_manager
|
||||
from ..managers.ble_manager import get_ble_manager
|
||||
from ..managers.os_update_manager import get_os_update_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -676,14 +680,14 @@ async def get_ssl_info():
|
||||
["openssl", "x509", "-in", cert_path, "-noout", "-ext", "subjectAltName"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0 and "subjectAltName" in result.stdout:
|
||||
if result.returncode == 0 and ("subjectAltName" in result.stdout or "Subject Alternative Name" in result.stdout):
|
||||
# Parse SANs from output like "DNS:localhost, IP:192.168.4.1"
|
||||
san_line = result.stdout.strip()
|
||||
for line in san_line.split("\n"):
|
||||
if "DNS:" in line or "IP:" in line:
|
||||
if "DNS:" in line or "IP" in line:
|
||||
# Split by comma and clean up
|
||||
sans = [s.strip() for s in line.split(",")]
|
||||
cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP:"))]
|
||||
cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP"))]
|
||||
break
|
||||
|
||||
# Get SHA256 fingerprint
|
||||
@@ -738,10 +742,19 @@ async def regenerate_ssl_certificate(request: SSLRegenerateRequest):
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
script_path = "/opt/dangerous-pi/scripts/generate-ssl-cert.sh"
|
||||
# Check multiple possible locations for the script
|
||||
script_candidates = [
|
||||
"/opt/dangerous-pi/scripts/generate-ssl-cert.sh",
|
||||
os.path.expanduser("~/dangerous-pi/scripts/generate-ssl-cert.sh"),
|
||||
os.path.join(os.path.dirname(__file__), "../../../scripts/generate-ssl-cert.sh"),
|
||||
]
|
||||
script_path = None
|
||||
for candidate in script_candidates:
|
||||
if os.path.exists(candidate):
|
||||
script_path = candidate
|
||||
break
|
||||
|
||||
# Check if script exists
|
||||
if not os.path.exists(script_path):
|
||||
if not script_path:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="SSL certificate generation script not found"
|
||||
@@ -975,4 +988,217 @@ async def clear_dismissed_widgets():
|
||||
plugin_manager = get_plugin_manager()
|
||||
plugin_manager.clear_dismissed()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OS Updates API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/os/info")
|
||||
async def get_os_info():
|
||||
"""Get OS-level system information.
|
||||
|
||||
Returns Debian version, kernel, architecture, uptime, hostname,
|
||||
last apt update timestamp, auto-update setting, and reboot-required status.
|
||||
"""
|
||||
manager = get_os_update_manager()
|
||||
return await manager.get_os_info()
|
||||
|
||||
|
||||
@router.get("/os/updates")
|
||||
async def get_os_updates(refresh: bool = False):
|
||||
"""Get available OS package updates.
|
||||
|
||||
Returns a list of upgradable packages with current and available versions.
|
||||
Results are cached for 1 hour unless refresh=True.
|
||||
"""
|
||||
manager = get_os_update_manager()
|
||||
packages = await manager.check_available_updates(force_refresh=refresh)
|
||||
return {
|
||||
"count": len(packages),
|
||||
"packages": packages,
|
||||
"upgrading": manager.is_upgrading,
|
||||
}
|
||||
|
||||
|
||||
class OsUpgradeRequest(BaseModel):
|
||||
"""Request to trigger an OS package upgrade."""
|
||||
security_only: bool = False
|
||||
|
||||
|
||||
@router.post("/os/update")
|
||||
async def run_os_update(request: OsUpgradeRequest):
|
||||
"""Trigger an OS package upgrade.
|
||||
|
||||
Runs `apt-get upgrade -y` (or unattended-upgrade --verbose for security-only).
|
||||
Only one upgrade can run at a time.
|
||||
|
||||
Args:
|
||||
security_only: If True, only install security updates
|
||||
"""
|
||||
manager = get_os_update_manager()
|
||||
result = await manager.run_upgrade(security_only=request.security_only)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=409 if "already in progress" in result.get("error", "") else 500,
|
||||
detail=result.get("error", "Upgrade failed"))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class AutoUpdatesRequest(BaseModel):
|
||||
"""Request to toggle automatic security updates."""
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.post("/os/auto-updates")
|
||||
async def toggle_auto_updates(request: AutoUpdatesRequest):
|
||||
"""Toggle automatic security updates.
|
||||
|
||||
Writes AUTO_SECURITY_UPDATES to the .env file and restarts the
|
||||
systemd timer that controls unattended-upgrades.
|
||||
|
||||
Args:
|
||||
enabled: True to enable, False to disable automatic security updates
|
||||
"""
|
||||
manager = get_os_update_manager()
|
||||
result = await manager.toggle_auto_updates(request.enabled)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error", "Failed to toggle auto-updates"))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ThemeDefinitionResponse(BaseModel):
|
||||
"""A single theme definition."""
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
supportsModes: List[str] = ["dark", "light", "auto"]
|
||||
defaultMode: str = "dark"
|
||||
author: str = ""
|
||||
css_url: str = ""
|
||||
source: str = "builtin" # "builtin" or "plugin"
|
||||
|
||||
|
||||
class ThemeRegistryResponse(BaseModel):
|
||||
"""Response for GET /api/system/themes."""
|
||||
themes: List[ThemeDefinitionResponse]
|
||||
|
||||
|
||||
def _scan_theme_dirs() -> List[ThemeDefinitionResponse]:
|
||||
"""Scan themes directories for installed theme packages."""
|
||||
themes_found: List[ThemeDefinitionResponse] = []
|
||||
|
||||
# Search paths: dev + production
|
||||
themes_dirs = [
|
||||
Path(__file__).parent.parent.parent / "frontend" / "themes", # dev
|
||||
Path("/opt/dangerous-pi/app/frontend/themes"), # prod
|
||||
]
|
||||
|
||||
seen_ids: set = set()
|
||||
for themes_dir in themes_dirs:
|
||||
if not themes_dir.is_dir():
|
||||
continue
|
||||
for entry in sorted(themes_dir.iterdir()):
|
||||
if not entry.is_dir() or entry.name.startswith("."):
|
||||
continue
|
||||
theme_json = entry / "theme.json"
|
||||
if not theme_json.exists():
|
||||
continue
|
||||
try:
|
||||
meta = json.loads(theme_json.read_text())
|
||||
theme_id = meta.get("id", entry.name)
|
||||
if theme_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(theme_id)
|
||||
themes_found.append(ThemeDefinitionResponse(
|
||||
id=theme_id,
|
||||
name=meta.get("name", theme_id),
|
||||
description=meta.get("description", ""),
|
||||
supportsModes=meta.get("supportsModes", ["dark", "light", "auto"]),
|
||||
defaultMode=meta.get("defaultMode", "dark"),
|
||||
author=meta.get("author", ""),
|
||||
css_url=f"/themes/{theme_id}/tokens.css",
|
||||
source="builtin",
|
||||
))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
print(f"Warning: bad theme.json in {entry}: {exc}")
|
||||
|
||||
return themes_found
|
||||
|
||||
|
||||
def _scan_plugin_themes() -> List[ThemeDefinitionResponse]:
|
||||
"""Collect themes registered by plugins via the theme_register hook."""
|
||||
from ..managers.plugin_manager import get_plugin_manager
|
||||
pm = get_plugin_manager()
|
||||
|
||||
themes: List[ThemeDefinitionResponse] = []
|
||||
if "theme_register" not in pm._hooks:
|
||||
return themes
|
||||
|
||||
import asyncio
|
||||
results = []
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
# Hooks are called synchronously here since they are simple data returns
|
||||
for callback in pm._hooks.get("theme_register", []):
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(callback):
|
||||
# Schedule in running loop if available
|
||||
if loop:
|
||||
import concurrent.futures
|
||||
# Can't await in sync context; skip async hooks
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
result = callback()
|
||||
if result:
|
||||
results.append(result)
|
||||
except Exception as exc:
|
||||
print(f"Warning: theme_register hook error: {exc}")
|
||||
|
||||
for r in results:
|
||||
themes.append(ThemeDefinitionResponse(
|
||||
id=r.get("id", "unknown"),
|
||||
name=r.get("name", "Unknown Theme"),
|
||||
description=r.get("description", ""),
|
||||
supportsModes=r.get("supportsModes", ["dark", "light", "auto"]),
|
||||
defaultMode=r.get("defaultMode", "dark"),
|
||||
author=r.get("author", ""),
|
||||
css_url=r.get("css_url", f"/themes/{r.get('id', 'unknown')}/tokens.css"),
|
||||
source="plugin",
|
||||
))
|
||||
|
||||
return themes
|
||||
|
||||
|
||||
@router.get("/themes", response_model=ThemeRegistryResponse)
|
||||
async def get_available_themes():
|
||||
"""Get available themes from disk and plugin registry.
|
||||
|
||||
Scans the themes/ directory for installed theme packages
|
||||
and collects themes registered by plugins via the theme_register hook.
|
||||
"""
|
||||
builtin = _scan_theme_dirs()
|
||||
plugin_themes = _scan_plugin_themes()
|
||||
|
||||
# Merge, preferring builtin for duplicate IDs
|
||||
seen = {t.id for t in builtin}
|
||||
all_themes = list(builtin)
|
||||
for pt in plugin_themes:
|
||||
if pt.id not in seen:
|
||||
all_themes.append(pt)
|
||||
seen.add(pt.id)
|
||||
|
||||
return ThemeRegistryResponse(themes=all_themes)
|
||||
|
||||
return {"success": True, "message": "Dismissed widgets cleared"}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
Refactored to use UpdateService for all business logic.
|
||||
Endpoints are now thin adapters that convert HTTP requests/responses.
|
||||
|
||||
Supports both legacy whole-system operations and per-component operations.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, HTTPException, Path
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..services.container import container
|
||||
@@ -14,6 +16,31 @@ from ..managers.ble_manager import get_ble_manager, NotificationType
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response / request models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ComponentUpdateInfo(BaseModel):
|
||||
"""Per-component update information."""
|
||||
component_id: str
|
||||
current_version: Optional[str] = None
|
||||
available_version: str
|
||||
changelog: str = ""
|
||||
download_size: Optional[int] = None
|
||||
compatible: bool = True
|
||||
incompatible_reason: Optional[str] = None
|
||||
|
||||
|
||||
class PluginUpdateInfo(BaseModel):
|
||||
"""Per-plugin update information."""
|
||||
plugin_id: str
|
||||
current_version: str
|
||||
available_version: str
|
||||
changelog: str = ""
|
||||
download_size: Optional[int] = None
|
||||
source_repo: str = ""
|
||||
|
||||
|
||||
class UpdateCheckResponse(BaseModel):
|
||||
"""Response model for update check."""
|
||||
update_available: bool
|
||||
@@ -24,6 +51,8 @@ class UpdateCheckResponse(BaseModel):
|
||||
is_prerelease: bool = False
|
||||
download_size: Optional[int] = None
|
||||
message: Optional[str] = None
|
||||
components: List[ComponentUpdateInfo] = []
|
||||
plugins: List[PluginUpdateInfo] = []
|
||||
|
||||
|
||||
class UpdateProgressResponse(BaseModel):
|
||||
@@ -34,6 +63,8 @@ class UpdateProgressResponse(BaseModel):
|
||||
download_progress: float = 0.0
|
||||
error_message: Optional[str] = None
|
||||
last_check: Optional[str] = None
|
||||
active_component: Optional[str] = None
|
||||
components: List[dict] = []
|
||||
|
||||
|
||||
class ReleaseNotesRequest(BaseModel):
|
||||
@@ -41,15 +72,17 @@ class ReleaseNotesRequest(BaseModel):
|
||||
version: Optional[str] = None
|
||||
|
||||
|
||||
class ComponentDownloadRequest(BaseModel):
|
||||
"""Optional request body for selective download."""
|
||||
components: Optional[List[str]] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _service_error_to_http_status(error_code: str) -> int:
|
||||
"""Map service error codes to HTTP status codes.
|
||||
|
||||
Args:
|
||||
error_code: Service error code
|
||||
|
||||
Returns:
|
||||
HTTP status code
|
||||
"""
|
||||
"""Map service error codes to HTTP status codes."""
|
||||
codes = {
|
||||
"update_check_error": 500,
|
||||
"no_update_available": 400,
|
||||
@@ -62,35 +95,44 @@ def _service_error_to_http_status(error_code: str) -> int:
|
||||
"release_notes_error": 500,
|
||||
"check_download_error": 500,
|
||||
"full_update_error": 500,
|
||||
"manifest_error": 500,
|
||||
"component_not_available": 400,
|
||||
"component_download_error": 500,
|
||||
"component_install_error": 500,
|
||||
"rollback_error": 500,
|
||||
}
|
||||
return codes.get(error_code, 500)
|
||||
|
||||
|
||||
@router.get("/check", response_model=UpdateCheckResponse)
|
||||
async def check_for_updates():
|
||||
"""Check for available updates.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
result = await container.update_service.check_for_updates()
|
||||
|
||||
def _raise_on_error(result):
|
||||
"""Raise HTTPException if result indicates failure."""
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
detail=result.error.message,
|
||||
)
|
||||
|
||||
# Send BLE notification if update is available
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy endpoints (backward-compatible)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/check", response_model=UpdateCheckResponse)
|
||||
async def check_for_updates():
|
||||
"""Check for available updates (components + plugins)."""
|
||||
result = await container.update_service.check_for_updates()
|
||||
_raise_on_error(result)
|
||||
|
||||
# BLE notification
|
||||
if result.data.get("update_available"):
|
||||
try:
|
||||
ble_manager = get_ble_manager()
|
||||
await ble_manager.send_notification(
|
||||
NotificationType.UPDATE_AVAILABLE,
|
||||
f"Update available: v{result.data['latest_version']}",
|
||||
{"version": result.data["latest_version"]}
|
||||
{"version": result.data["latest_version"]},
|
||||
)
|
||||
except Exception:
|
||||
# BLE notification failure shouldn't affect the response
|
||||
pass
|
||||
|
||||
return UpdateCheckResponse(**result.data)
|
||||
@@ -98,108 +140,126 @@ async def check_for_updates():
|
||||
|
||||
@router.get("/progress", response_model=UpdateProgressResponse)
|
||||
async def get_update_progress():
|
||||
"""Get current update progress.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
"""Get current update progress."""
|
||||
result = await container.update_service.get_progress()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return UpdateProgressResponse(**result.data)
|
||||
|
||||
|
||||
@router.post("/download")
|
||||
async def download_update():
|
||||
"""Download the available update.
|
||||
async def download_update(body: Optional[ComponentDownloadRequest] = None):
|
||||
"""Download available updates.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
Without a body, downloads all available components (legacy behavior).
|
||||
With ``{"components": ["frontend"]}``, downloads only the specified ones.
|
||||
"""
|
||||
if body and body.components:
|
||||
results = {}
|
||||
for comp_id in body.components:
|
||||
r = await container.update_service.download_component(comp_id)
|
||||
_raise_on_error(r)
|
||||
results[comp_id] = r.data
|
||||
return {"message": "Components downloaded", "components": results}
|
||||
|
||||
result = await container.update_service.download_update()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return {"message": result.data["message"]}
|
||||
|
||||
|
||||
@router.post("/install")
|
||||
async def install_update():
|
||||
"""Install the downloaded update.
|
||||
async def install_update(body: Optional[ComponentDownloadRequest] = None):
|
||||
"""Install downloaded updates.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
Without a body, installs all downloaded components (legacy behavior).
|
||||
With ``{"components": ["frontend"]}``, installs only the specified ones.
|
||||
"""
|
||||
if body and body.components:
|
||||
results = {}
|
||||
for comp_id in body.components:
|
||||
r = await container.update_service.install_component(comp_id)
|
||||
_raise_on_error(r)
|
||||
results[comp_id] = r.data
|
||||
return {"message": "Components installed", "components": results}
|
||||
|
||||
result = await container.update_service.install_update()
|
||||
_raise_on_error(result)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
# Send BLE notification
|
||||
# BLE notification
|
||||
try:
|
||||
ble_manager = get_ble_manager()
|
||||
await ble_manager.send_notification(
|
||||
NotificationType.UPDATE_COMPLETE,
|
||||
"Update installed successfully",
|
||||
{"restart_required": True}
|
||||
{"restart_required": True},
|
||||
)
|
||||
except Exception:
|
||||
# BLE notification failure shouldn't affect the response
|
||||
pass
|
||||
|
||||
return {
|
||||
"message": result.data["message"],
|
||||
"restart_required": result.data.get("requires_restart", True)
|
||||
"restart_required": result.data.get("requires_restart", True),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/release-notes", response_model=dict)
|
||||
async def get_release_notes(request: ReleaseNotesRequest):
|
||||
"""Get release notes for a specific version.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
|
||||
Args:
|
||||
request: Version to get notes for (latest if not specified)
|
||||
"""
|
||||
"""Get release notes for a specific version."""
|
||||
result = await container.update_service.get_release_notes(request.version)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return {
|
||||
"version": result.data["version"],
|
||||
"notes": result.data["release_notes"]
|
||||
"notes": result.data["release_notes"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/current-version")
|
||||
async def get_current_version():
|
||||
"""Get current system version.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
"""Get current system version."""
|
||||
result = await container.update_service.get_progress()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return {
|
||||
"version": result.data["current_version"],
|
||||
"last_check": result.data["last_check"]
|
||||
"last_check": result.data["last_check"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Component-level endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/components")
|
||||
async def get_installed_components():
|
||||
"""Get installed component manifest."""
|
||||
result = await container.update_service.get_installed_components()
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
|
||||
@router.post("/components/{component_id}/download")
|
||||
async def download_component(
|
||||
component_id: str = Path(description="Component ID (pm3, frontend, backend, theme)"),
|
||||
):
|
||||
"""Download a single component update."""
|
||||
result = await container.update_service.download_component(component_id)
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
|
||||
@router.post("/components/{component_id}/install")
|
||||
async def install_component(
|
||||
component_id: str = Path(description="Component ID"),
|
||||
):
|
||||
"""Install a single downloaded component."""
|
||||
result = await container.update_service.install_component(component_id)
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
|
||||
@router.post("/components/{component_id}/rollback")
|
||||
async def rollback_component(
|
||||
component_id: str = Path(description="Component ID"),
|
||||
):
|
||||
"""Rollback a component to its backup."""
|
||||
result = await container.update_service.rollback_component(component_id)
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
Reference in New Issue
Block a user