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:
@@ -210,7 +210,7 @@ class BLEManager:
|
||||
}
|
||||
|
||||
async def _check_bluetooth_adapter(self) -> bool:
|
||||
"""Check if Bluetooth adapter is available.
|
||||
"""Check if Bluetooth adapter is available, unblocking and powering on if needed.
|
||||
|
||||
Returns:
|
||||
True if adapter is available, False otherwise
|
||||
@@ -224,8 +224,20 @@ class BLEManager:
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
# If we get output with "Controller", we have an adapter
|
||||
return b"Controller" in stdout
|
||||
if b"Controller" not in stdout:
|
||||
return False
|
||||
|
||||
# Unblock Bluetooth if soft-blocked by rfkill
|
||||
await self._run_cmd("rfkill", "unblock", "bluetooth")
|
||||
|
||||
# Power on the adapter via bluetoothctl
|
||||
out = await self._run_cmd("bluetoothctl", "power", "on")
|
||||
if b"succeeded" in out.lower() or b"yes" in out.lower():
|
||||
logger.info("Bluetooth adapter powered on")
|
||||
else:
|
||||
logger.warning("Bluetooth power on response: %s", out.decode(errors='replace').strip())
|
||||
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning("bluetoothctl not found - BlueZ not installed")
|
||||
@@ -234,6 +246,19 @@ class BLEManager:
|
||||
logger.error("Error checking Bluetooth adapter: %s", e)
|
||||
return False
|
||||
|
||||
async def _run_cmd(self, *args: str) -> bytes:
|
||||
"""Run a command and return stdout."""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await process.communicate()
|
||||
return stdout
|
||||
except FileNotFoundError:
|
||||
return b""
|
||||
|
||||
async def _set_device_name(self, name: str):
|
||||
"""Set the Bluetooth device name.
|
||||
|
||||
|
||||
312
app/backend/managers/os_update_manager.py
Normal file
312
app/backend/managers/os_update_manager.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""OS Update Manager for Dangerous Pi.
|
||||
|
||||
Provides visibility into OS-level package updates and allows triggering
|
||||
apt upgrades from the web UI. Works alongside unattended-upgrades which
|
||||
handles automatic security patches.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
@dataclass
|
||||
class OsPackageUpdate:
|
||||
"""A single upgradable OS package."""
|
||||
name: str
|
||||
current_version: str
|
||||
available_version: str
|
||||
architecture: str = ""
|
||||
origin: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OsInfo:
|
||||
"""OS-level system information."""
|
||||
debian_version: str = ""
|
||||
debian_codename: str = ""
|
||||
kernel: str = ""
|
||||
architecture: str = ""
|
||||
uptime_seconds: float = 0
|
||||
hostname: str = ""
|
||||
last_apt_update: Optional[str] = None
|
||||
auto_security_updates: bool = True
|
||||
reboot_required: bool = False
|
||||
|
||||
|
||||
class OsUpdateManager:
|
||||
"""Manages OS-level package updates."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: List[OsPackageUpdate] = []
|
||||
self._cache_time: float = 0
|
||||
self._cache_ttl: float = 3600 # 1 hour
|
||||
self._upgrading: bool = False
|
||||
self._upgrade_output: List[str] = []
|
||||
self._env_path = Path(os.getenv("ENV_FILE", "/opt/dangerous-pi/.env"))
|
||||
|
||||
async def get_os_info(self) -> Dict[str, Any]:
|
||||
"""Get OS-level system information."""
|
||||
info = OsInfo()
|
||||
|
||||
# Debian version
|
||||
try:
|
||||
os_release = Path("/etc/os-release")
|
||||
if os_release.exists():
|
||||
content = os_release.read_text()
|
||||
for line in content.splitlines():
|
||||
if line.startswith("VERSION_ID="):
|
||||
info.debian_version = line.split("=", 1)[1].strip('"')
|
||||
elif line.startswith("VERSION_CODENAME="):
|
||||
info.debian_codename = line.split("=", 1)[1].strip('"')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Kernel
|
||||
info.kernel = platform.release()
|
||||
|
||||
# Architecture
|
||||
info.architecture = platform.machine()
|
||||
|
||||
# Uptime
|
||||
try:
|
||||
uptime_path = Path("/proc/uptime")
|
||||
if uptime_path.exists():
|
||||
info.uptime_seconds = float(uptime_path.read_text().split()[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Hostname
|
||||
info.hostname = platform.node()
|
||||
|
||||
# Last apt update (mtime of apt lists directory)
|
||||
try:
|
||||
apt_lists = Path("/var/lib/apt/lists")
|
||||
if apt_lists.exists():
|
||||
mtime = apt_lists.stat().st_mtime
|
||||
from datetime import datetime, timezone
|
||||
info.last_apt_update = datetime.fromtimestamp(
|
||||
mtime, tz=timezone.utc
|
||||
).isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Auto security updates setting
|
||||
info.auto_security_updates = self._read_auto_updates_setting()
|
||||
|
||||
# Reboot required
|
||||
info.reboot_required = Path("/var/run/reboot-required").exists()
|
||||
|
||||
return {
|
||||
"debian_version": info.debian_version,
|
||||
"debian_codename": info.debian_codename,
|
||||
"kernel": info.kernel,
|
||||
"architecture": info.architecture,
|
||||
"uptime_seconds": info.uptime_seconds,
|
||||
"hostname": info.hostname,
|
||||
"last_apt_update": info.last_apt_update,
|
||||
"auto_security_updates": info.auto_security_updates,
|
||||
"reboot_required": info.reboot_required,
|
||||
}
|
||||
|
||||
async def check_available_updates(self, force_refresh: bool = False) -> List[Dict[str, str]]:
|
||||
"""Check for available OS package updates.
|
||||
|
||||
Returns cached results unless force_refresh=True or cache has expired.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if not force_refresh and self._cache and (now - self._cache_time) < self._cache_ttl:
|
||||
return [self._package_to_dict(p) for p in self._cache]
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"apt", "list", "--upgradable", "-qq",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=60)
|
||||
output = stdout.decode(errors="replace").strip()
|
||||
|
||||
packages = []
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("Listing"):
|
||||
continue
|
||||
pkg = self._parse_apt_line(line)
|
||||
if pkg:
|
||||
packages.append(pkg)
|
||||
|
||||
self._cache = packages
|
||||
self._cache_time = now
|
||||
|
||||
except (asyncio.TimeoutError, Exception) as e:
|
||||
# Return stale cache on error rather than failing
|
||||
if not self._cache:
|
||||
return [{"name": "error", "current_version": "", "available_version": str(e), "architecture": "", "origin": ""}]
|
||||
|
||||
return [self._package_to_dict(p) for p in self._cache]
|
||||
|
||||
async def run_upgrade(self, security_only: bool = False) -> Dict[str, Any]:
|
||||
"""Trigger an apt upgrade.
|
||||
|
||||
Returns the result after completion. Only one upgrade can run at a time.
|
||||
Uses create_subprocess_exec (not shell) to avoid injection risks.
|
||||
"""
|
||||
if self._upgrading:
|
||||
return {"success": False, "error": "An upgrade is already in progress"}
|
||||
|
||||
self._upgrading = True
|
||||
self._upgrade_output = []
|
||||
|
||||
try:
|
||||
if security_only:
|
||||
cmd = ["sudo", "unattended-upgrade", "--verbose"]
|
||||
else:
|
||||
cmd = ["sudo", "apt-get", "upgrade", "-y"]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
|
||||
)
|
||||
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
decoded = line.decode(errors="replace").rstrip()
|
||||
self._upgrade_output.append(decoded)
|
||||
|
||||
await proc.wait()
|
||||
|
||||
# Invalidate cache after upgrade
|
||||
self._cache = []
|
||||
self._cache_time = 0
|
||||
|
||||
success = proc.returncode == 0
|
||||
return {
|
||||
"success": success,
|
||||
"return_code": proc.returncode,
|
||||
"output": self._upgrade_output,
|
||||
"reboot_required": Path("/var/run/reboot-required").exists(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e), "output": self._upgrade_output}
|
||||
|
||||
finally:
|
||||
self._upgrading = False
|
||||
|
||||
async def toggle_auto_updates(self, enabled: bool) -> Dict[str, Any]:
|
||||
"""Toggle automatic security updates by writing to .env and restarting the timer."""
|
||||
try:
|
||||
self._write_auto_updates_setting(enabled)
|
||||
|
||||
# Restart the toggle service to apply
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "systemctl", "restart", "dangerous-pi-auto-updates.service",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"auto_security_updates": enabled,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@property
|
||||
def is_upgrading(self) -> bool:
|
||||
return self._upgrading
|
||||
|
||||
@property
|
||||
def upgrade_output(self) -> List[str]:
|
||||
return list(self._upgrade_output)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _parse_apt_line(self, line: str) -> Optional[OsPackageUpdate]:
|
||||
"""Parse a line from `apt list --upgradable -qq`.
|
||||
|
||||
Format: package/origin version arch [upgradable from: old_version]
|
||||
"""
|
||||
match = re.match(
|
||||
r"^(\S+?)(?:/(\S+))?\s+(\S+)\s+(\S+)\s+\[upgradable from:\s+(\S+)\]",
|
||||
line,
|
||||
)
|
||||
if match:
|
||||
return OsPackageUpdate(
|
||||
name=match.group(1),
|
||||
origin=match.group(2) or "",
|
||||
available_version=match.group(3),
|
||||
architecture=match.group(4),
|
||||
current_version=match.group(5),
|
||||
)
|
||||
return None
|
||||
|
||||
def _package_to_dict(self, pkg: OsPackageUpdate) -> Dict[str, str]:
|
||||
return {
|
||||
"name": pkg.name,
|
||||
"current_version": pkg.current_version,
|
||||
"available_version": pkg.available_version,
|
||||
"architecture": pkg.architecture,
|
||||
"origin": pkg.origin,
|
||||
}
|
||||
|
||||
def _read_auto_updates_setting(self) -> bool:
|
||||
"""Read AUTO_SECURITY_UPDATES from .env file."""
|
||||
try:
|
||||
if self._env_path.exists():
|
||||
for line in self._env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("AUTO_SECURITY_UPDATES="):
|
||||
val = line.split("=", 1)[1].strip().lower()
|
||||
return val != "false"
|
||||
except Exception:
|
||||
pass
|
||||
return True # Default: enabled
|
||||
|
||||
def _write_auto_updates_setting(self, enabled: bool):
|
||||
"""Write AUTO_SECURITY_UPDATES to .env file."""
|
||||
value = "true" if enabled else "false"
|
||||
env_line = f"AUTO_SECURITY_UPDATES={value}"
|
||||
|
||||
if not self._env_path.exists():
|
||||
self._env_path.write_text(env_line + "\n")
|
||||
return
|
||||
|
||||
lines = self._env_path.read_text().splitlines()
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith("AUTO_SECURITY_UPDATES="):
|
||||
lines[i] = env_line
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
lines.append(env_line)
|
||||
|
||||
self._env_path.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
# Singleton
|
||||
_os_update_manager: Optional[OsUpdateManager] = None
|
||||
|
||||
|
||||
def get_os_update_manager() -> OsUpdateManager:
|
||||
"""Get or create the OS update manager singleton."""
|
||||
global _os_update_manager
|
||||
if _os_update_manager is None:
|
||||
_os_update_manager = OsUpdateManager()
|
||||
return _os_update_manager
|
||||
@@ -8,6 +8,7 @@ import asyncio
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from datetime import datetime, timezone
|
||||
@@ -16,6 +17,8 @@ from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Callable, Set
|
||||
import sys
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
@@ -400,6 +403,18 @@ class PluginBase:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginUpdateInfo:
|
||||
"""Information about an available plugin update."""
|
||||
plugin_id: str
|
||||
current_version: str
|
||||
available_version: str
|
||||
changelog: str = ""
|
||||
download_url: str = ""
|
||||
download_size: Optional[int] = None
|
||||
source_repo: str = ""
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages plugin loading, enabling, lifecycle, and header widgets."""
|
||||
|
||||
@@ -790,6 +805,121 @@ class PluginManager:
|
||||
del self._header_widgets[widget_id]
|
||||
print(f"Expired widget removed: {widget_id}")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Plugin Update Checking
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_github_repo(homepage: Optional[str]) -> Optional[str]:
|
||||
"""Extract 'owner/repo' from a GitHub URL.
|
||||
|
||||
Args:
|
||||
homepage: Plugin homepage URL
|
||||
|
||||
Returns:
|
||||
'owner/repo' string or None
|
||||
"""
|
||||
if not homepage:
|
||||
return None
|
||||
m = re.match(r"https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", homepage)
|
||||
return m.group(1) if m else None
|
||||
|
||||
async def check_plugin_updates(self) -> List[PluginUpdateInfo]:
|
||||
"""Check all installed plugins for available updates.
|
||||
|
||||
Queries each plugin's GitHub repo (derived from homepage) for
|
||||
newer releases. Skips plugins without a GitHub homepage.
|
||||
|
||||
Returns:
|
||||
List of PluginUpdateInfo for plugins with updates available
|
||||
"""
|
||||
updates: List[PluginUpdateInfo] = []
|
||||
|
||||
plugins_to_check = []
|
||||
for plugin_id, info in self._plugins.items():
|
||||
repo = self._extract_github_repo(info.metadata.homepage)
|
||||
if repo:
|
||||
plugins_to_check.append((plugin_id, info, repo))
|
||||
|
||||
if not plugins_to_check:
|
||||
return updates
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for plugin_id, info, repo in plugins_to_check:
|
||||
try:
|
||||
update = await self._check_single_plugin_update(
|
||||
session, plugin_id, info, repo
|
||||
)
|
||||
if update:
|
||||
updates.append(update)
|
||||
except Exception as e:
|
||||
print(f"Error checking updates for plugin {plugin_id}: {e}")
|
||||
|
||||
return updates
|
||||
|
||||
async def _check_single_plugin_update(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
plugin_id: str,
|
||||
info: PluginInfo,
|
||||
repo: str,
|
||||
) -> Optional[PluginUpdateInfo]:
|
||||
"""Check a single plugin for available update.
|
||||
|
||||
Args:
|
||||
session: aiohttp session
|
||||
plugin_id: Plugin identifier
|
||||
info: Current plugin info
|
||||
repo: GitHub 'owner/repo' string
|
||||
|
||||
Returns:
|
||||
PluginUpdateInfo if update available, None otherwise
|
||||
"""
|
||||
url = f"https://api.github.com/repos/{repo}/releases/latest"
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
data = await resp.json()
|
||||
|
||||
latest_tag = data.get("tag_name", "").lstrip("v")
|
||||
current_ver = info.metadata.version
|
||||
|
||||
if not self._is_newer_plugin_version(latest_tag, current_ver):
|
||||
return None
|
||||
|
||||
# Find a suitable download asset
|
||||
download_url = ""
|
||||
download_size = None
|
||||
for asset in data.get("assets", []):
|
||||
if asset["name"].endswith((".tar.gz", ".zip")):
|
||||
download_url = asset["browser_download_url"]
|
||||
download_size = asset.get("size")
|
||||
break
|
||||
|
||||
# Fall back to source tarball
|
||||
if not download_url:
|
||||
download_url = data.get("tarball_url", "")
|
||||
|
||||
return PluginUpdateInfo(
|
||||
plugin_id=plugin_id,
|
||||
current_version=current_ver,
|
||||
available_version=latest_tag,
|
||||
changelog=data.get("body", ""),
|
||||
download_url=download_url,
|
||||
download_size=download_size,
|
||||
source_repo=repo,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_newer_plugin_version(latest: str, current: str) -> bool:
|
||||
"""Compare two version strings, returning True if latest > current."""
|
||||
def parse(v: str) -> tuple:
|
||||
return tuple(int(x) for x in re.split(r'[-+]', v)[0].split('.'))
|
||||
try:
|
||||
return parse(latest) > parse(current)
|
||||
except (ValueError, AttributeError):
|
||||
return latest != current
|
||||
|
||||
|
||||
# Global plugin manager instance
|
||||
_plugin_manager: Optional[PluginManager] = None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user