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"}
|
||||
|
||||
Reference in New Issue
Block a user