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:
michael
2026-03-04 12:01:01 -08:00
parent 2ec89041ef
commit a9acdb85ce
163 changed files with 8124 additions and 921 deletions

View File

@@ -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"}

View File

@@ -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