mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-07 09:23:06 +02:00
Add OTA update functionality with new API endpoints and upgrade script
This commit is contained in:
@@ -372,10 +372,31 @@ EOF
|
||||
mkdir -p /etc/sudoers.d
|
||||
cat > /etc/sudoers.d/pymc-repeater <<'EOF'
|
||||
# Allow repeater user to manage the pymc-repeater service without password
|
||||
repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater
|
||||
repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater, /usr/local/bin/pymc-do-upgrade
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/pymc-repeater
|
||||
|
||||
echo ">>> Installing OTA upgrade wrapper..."
|
||||
cat > /usr/local/bin/pymc-do-upgrade <<'UPGRADEEOF'
|
||||
#!/bin/bash
|
||||
# pymc-do-upgrade: invoked by the repeater service user via sudo for OTA upgrades.
|
||||
# Usage: sudo /usr/local/bin/pymc-do-upgrade [channel]
|
||||
set -e
|
||||
CHANNEL="${1:-main}"
|
||||
# Validate: only allow safe git ref characters
|
||||
if ! [[ "$CHANNEL" =~ ^[a-zA-Z0-9._/-]{1,80}$ ]]; then
|
||||
echo "Invalid channel name: $CHANNEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
export PIP_ROOT_USER_ACTION=ignore
|
||||
exec python3 -m pip install \
|
||||
--break-system-packages \
|
||||
--no-cache-dir \
|
||||
--force-reinstall \
|
||||
"git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}[hardware]"
|
||||
UPGRADEEOF
|
||||
chmod 0755 /usr/local/bin/pymc-do-upgrade
|
||||
|
||||
echo "75"; echo "# Starting service..."
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
|
||||
@@ -685,9 +706,29 @@ EOF
|
||||
mkdir -p /etc/sudoers.d
|
||||
cat > /etc/sudoers.d/pymc-repeater <<'EOF'
|
||||
# Allow repeater user to manage the pymc-repeater service without password
|
||||
repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater
|
||||
repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater, /usr/local/bin/pymc-do-upgrade
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/pymc-repeater
|
||||
# Install / refresh OTA upgrade wrapper
|
||||
cat > /usr/local/bin/pymc-do-upgrade <<'UPGRADEEOF'
|
||||
#!/bin/bash
|
||||
# pymc-do-upgrade: invoked by the repeater service user via sudo for OTA upgrades.
|
||||
# Usage: sudo /usr/local/bin/pymc-do-upgrade [channel]
|
||||
set -e
|
||||
CHANNEL="${1:-main}"
|
||||
# Validate: only allow safe git ref characters
|
||||
if ! [[ "$CHANNEL" =~ ^[a-zA-Z0-9._/-]{1,80}$ ]]; then
|
||||
echo "Invalid channel name: $CHANNEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
export PIP_ROOT_USER_ACTION=ignore
|
||||
exec python3 -m pip install \
|
||||
--break-system-packages \
|
||||
--no-cache-dir \
|
||||
--force-reinstall \
|
||||
"git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}[hardware]"
|
||||
UPGRADEEOF
|
||||
chmod 0755 /usr/local/bin/pymc-do-upgrade
|
||||
echo " ✓ Permissions updated"
|
||||
|
||||
echo "[7/9] Reloading systemd..."
|
||||
@@ -846,6 +887,7 @@ uninstall_repeater() {
|
||||
echo "50"; echo "# Removing polkit and sudoers rules..."
|
||||
rm -f /etc/polkit-1/rules.d/10-pymc-repeater.rules
|
||||
rm -f /etc/sudoers.d/pymc-repeater
|
||||
rm -f /usr/local/bin/pymc-do-upgrade
|
||||
|
||||
echo "60"; echo "# Removing installation..."
|
||||
rm -rf "$INSTALL_DIR"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from .api_endpoints import APIEndpoints
|
||||
from .cad_calibration_engine import CADCalibrationEngine
|
||||
from .http_server import HTTPStatsServer, LogBuffer, StatsApp, _log_buffer
|
||||
from .update_endpoints import UpdateAPIEndpoints
|
||||
|
||||
__all__ = [
|
||||
"HTTPStatsServer",
|
||||
@@ -8,5 +9,6 @@ __all__ = [
|
||||
"LogBuffer",
|
||||
"APIEndpoints",
|
||||
"CADCalibrationEngine",
|
||||
"UpdateAPIEndpoints",
|
||||
"_log_buffer",
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@ from .auth.middleware import require_auth
|
||||
from .auth_endpoints import AuthAPIEndpoints
|
||||
from .cad_calibration_engine import CADCalibrationEngine
|
||||
from .companion_endpoints import CompanionAPIEndpoints
|
||||
from .update_endpoints import UpdateAPIEndpoints
|
||||
|
||||
logger = logging.getLogger("HTTPServer")
|
||||
|
||||
@@ -113,6 +114,14 @@ logger = logging.getLogger("HTTPServer")
|
||||
# DELETE /api/room_message?room_name=General&message_id=123 - Delete specific message
|
||||
# DELETE /api/room_messages_clear?room_name=General - Clear all messages in room
|
||||
|
||||
# OTA Updates
|
||||
# GET /api/update/status - Current + latest version, channel, state
|
||||
# POST /api/update/check - Force fresh GitHub version check
|
||||
# POST /api/update/install - Start background upgrade; stream via /progress
|
||||
# GET /api/update/progress - SSE stream of live install log lines
|
||||
# GET /api/update/channels - List available release channels (branches)
|
||||
# POST /api/update/set_channel - Switch release channel {"channel": "dev"}
|
||||
|
||||
# Setup Wizard
|
||||
# GET /api/needs_setup - Check if repeater needs initial setup
|
||||
# GET /api/hardware_options - Get available hardware configurations
|
||||
@@ -163,6 +172,9 @@ class APIEndpoints:
|
||||
daemon_instance, event_loop, self.config, self.config_manager
|
||||
)
|
||||
|
||||
# Create nested update object for /api/update/* routes
|
||||
self.update = UpdateAPIEndpoints()
|
||||
|
||||
def _is_cors_enabled(self):
|
||||
return self.config.get("web", {}).get("cors_enabled", False)
|
||||
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
"""
|
||||
OTA Update endpoints for pyMC Repeater.
|
||||
|
||||
Provides server-side GitHub version checks, background pip-based upgrades with
|
||||
SSE progress streaming, and release-channel switching.
|
||||
|
||||
Endpoints (mounted at /api/update/):
|
||||
GET /api/update/status – current + latest version, channel, update state
|
||||
POST /api/update/check – force a fresh GitHub version check
|
||||
POST /api/update/install – start the upgrade in a background thread
|
||||
GET /api/update/progress – SSE stream of live install log lines
|
||||
GET /api/update/channels – list available branches/channels from GitHub
|
||||
POST /api/update/set_channel – switch the active release channel
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import cherrypy
|
||||
|
||||
from repeater import __version__ as _installed_version
|
||||
|
||||
logger = logging.getLogger("HTTPServer")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repository constants
|
||||
# ---------------------------------------------------------------------------
|
||||
GITHUB_OWNER = "rightup"
|
||||
GITHUB_REPO = "pyMC_Repeater"
|
||||
GITHUB_RAW_BASE = f"https://raw.githubusercontent.com/{GITHUB_OWNER}/{GITHUB_REPO}"
|
||||
GITHUB_API_BASE = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}"
|
||||
PACKAGE_NAME = "pymc_repeater"
|
||||
|
||||
# How long (seconds) before a cached check result expires
|
||||
CHECK_CACHE_TTL = 600 # 10 minutes
|
||||
|
||||
# Channels file – persisted so the choice survives daemon restarts
|
||||
_CHANNELS_FILE = "/var/lib/pymc_repeater/.update_channel"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level state (one update at a time)
|
||||
# ---------------------------------------------------------------------------
|
||||
class _UpdateState:
|
||||
"""Singleton-style mutable state shared between all endpoint calls."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
# version info
|
||||
self.current_version: str = _installed_version
|
||||
self.latest_version: Optional[str] = None
|
||||
self.has_update: bool = False
|
||||
self.channel: str = self._load_channel()
|
||||
self.last_checked: Optional[datetime] = None
|
||||
# progress / install state
|
||||
self.state: str = "idle" # idle | checking | installing | complete | error
|
||||
self.error_message: Optional[str] = None
|
||||
self.progress_lines: List[str] = []
|
||||
self._install_thread: Optional[threading.Thread] = None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Channel persistence #
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def _load_channel() -> str:
|
||||
try:
|
||||
if os.path.isfile(_CHANNELS_FILE):
|
||||
with open(_CHANNELS_FILE) as fh:
|
||||
ch = fh.read().strip()
|
||||
if ch:
|
||||
return ch
|
||||
except OSError:
|
||||
pass
|
||||
return "main"
|
||||
|
||||
def _save_channel(self, channel: str) -> None:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_CHANNELS_FILE), exist_ok=True)
|
||||
with open(_CHANNELS_FILE, "w") as fh:
|
||||
fh.write(channel)
|
||||
except OSError as exc:
|
||||
logger.warning(f"Could not persist channel choice: {exc}")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Thread-safe accessors #
|
||||
# ------------------------------------------------------------------ #
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
"current_version": self.current_version,
|
||||
"latest_version": self.latest_version,
|
||||
"has_update": self.has_update,
|
||||
"channel": self.channel,
|
||||
"last_checked": self.last_checked.isoformat() if self.last_checked else None,
|
||||
"state": self.state,
|
||||
"error": self.error_message,
|
||||
}
|
||||
|
||||
def set_channel(self, channel: str) -> None:
|
||||
with self._lock:
|
||||
self.channel = channel
|
||||
self._save_channel(channel)
|
||||
# Invalidate cached check so next call re-checks against new channel
|
||||
self.last_checked = None
|
||||
self.latest_version = None
|
||||
self.has_update = False
|
||||
|
||||
def _set_checking(self) -> bool:
|
||||
"""Return True and move to 'checking' if currently idle."""
|
||||
with self._lock:
|
||||
if self.state not in ("idle", "complete", "error"):
|
||||
return False
|
||||
self.state = "checking"
|
||||
return True
|
||||
|
||||
def _finish_check(self, latest: str) -> None:
|
||||
with self._lock:
|
||||
self.latest_version = latest
|
||||
self.current_version = _installed_version # refresh in case just updated
|
||||
self.has_update = (latest != self.current_version)
|
||||
self.last_checked = datetime.utcnow()
|
||||
self.state = "idle"
|
||||
self.error_message = None
|
||||
|
||||
def _fail_check(self, msg: str) -> None:
|
||||
with self._lock:
|
||||
self.state = "error"
|
||||
self.error_message = msg
|
||||
self.last_checked = datetime.utcnow()
|
||||
|
||||
def start_install(self, thread: threading.Thread) -> bool:
|
||||
with self._lock:
|
||||
if self.state == "installing":
|
||||
return False
|
||||
self.state = "installing"
|
||||
self.error_message = None
|
||||
self.progress_lines = ["[pyMC updater] Starting update…"]
|
||||
self._install_thread = thread
|
||||
return True
|
||||
|
||||
def finish_install(self, success: bool, msg: str) -> None:
|
||||
with self._lock:
|
||||
self.state = "complete" if success else "error"
|
||||
self.error_message = None if success else msg
|
||||
if success:
|
||||
self.progress_lines.append(f"[pyMC updater] ✓ {msg}")
|
||||
# Refresh installed version from importlib metadata
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_ver
|
||||
self.current_version = _pkg_ver(PACKAGE_NAME)
|
||||
except Exception:
|
||||
pass
|
||||
self.has_update = False
|
||||
else:
|
||||
self.progress_lines.append(f"[pyMC updater] ✗ {msg}")
|
||||
|
||||
def append_line(self, line: str) -> None:
|
||||
with self._lock:
|
||||
self.progress_lines.append(line)
|
||||
|
||||
|
||||
_state = _UpdateState()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_url(url: str, timeout: int = 10) -> str:
|
||||
"""Perform a simple GET and return text body, or raise on failure."""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": f"pymc-repeater/{_installed_version}"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read().decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _latest_version_from_raw(channel: str) -> str:
|
||||
"""
|
||||
Fetch repeater/__init__.py from *channel* on GitHub and extract __version__.
|
||||
Falls back to scanning the _version.py stub.
|
||||
"""
|
||||
raw_url = f"{GITHUB_RAW_BASE}/{channel}/repeater/__init__.py"
|
||||
text = _fetch_url(raw_url)
|
||||
# __init__.py doesn't embed version directly; it imports from _version.py
|
||||
# Try to read _version.py on the same channel instead
|
||||
try:
|
||||
ver_url = f"{GITHUB_RAW_BASE}/{channel}/repeater/_version.py"
|
||||
ver_text = _fetch_url(ver_url)
|
||||
m = re.search(r'version\s*=\s*["\']([^"\']+)["\']', ver_text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Last resort: try pyproject.toml static version field
|
||||
try:
|
||||
toml_url = f"{GITHUB_RAW_BASE}/{channel}/pyproject.toml"
|
||||
toml_text = _fetch_url(toml_url)
|
||||
m = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', toml_text, re.MULTILINE)
|
||||
if m:
|
||||
return m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _fetch_latest_version(channel: str) -> str:
|
||||
"""
|
||||
Multi-strategy version fetch. Returns version string or raises.
|
||||
Strategy:
|
||||
1. GitHub Releases API (only works when tagged releases exist)
|
||||
2. Raw _version.py / pyproject.toml from the branch
|
||||
"""
|
||||
# Strategy 1: releases API (works for stable main branch tags)
|
||||
if channel == "main":
|
||||
try:
|
||||
api_url = f"{GITHUB_API_BASE}/releases/latest"
|
||||
body = _fetch_url(api_url, timeout=8)
|
||||
data = json.loads(body)
|
||||
tag = data.get("tag_name", "")
|
||||
ver = tag.lstrip("v")
|
||||
if ver:
|
||||
return ver
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Strategy 2: raw source files on the branch
|
||||
return _latest_version_from_raw(channel)
|
||||
|
||||
|
||||
def _fetch_branches() -> List[str]:
|
||||
"""Return list of branch names from GitHub API."""
|
||||
try:
|
||||
body = _fetch_url(f"{GITHUB_API_BASE}/branches?per_page=30", timeout=8)
|
||||
data = json.loads(body)
|
||||
names = [b["name"] for b in data if isinstance(b, dict) and b.get("name")]
|
||||
# Prefer main/dev at the front
|
||||
priority = [n for n in ("main", "dev", "develop") if n in names]
|
||||
rest = [n for n in names if n not in priority]
|
||||
return priority + rest
|
||||
except Exception:
|
||||
return ["main"]
|
||||
|
||||
|
||||
def _do_check() -> None:
|
||||
"""Background thread: fetch latest version and update state."""
|
||||
channel = _state.channel
|
||||
try:
|
||||
latest = _fetch_latest_version(channel)
|
||||
_state._finish_check(latest)
|
||||
logger.info(f"[Update] Check complete – current={_state.current_version} latest={latest} channel={channel}")
|
||||
except Exception as exc:
|
||||
msg = str(exc)
|
||||
_state._fail_check(msg)
|
||||
logger.warning(f"[Update] Version check failed: {msg}")
|
||||
|
||||
|
||||
def _do_install() -> None:
|
||||
"""
|
||||
Background thread: install updated package then restart the service.
|
||||
|
||||
Privilege strategy (root check → sudo wrapper → direct pip):
|
||||
1. If running as root – call python3 -m pip directly.
|
||||
2. Otherwise – use ``sudo /usr/local/bin/pymc-do-upgrade <channel>``
|
||||
(installed and authorized by manage.sh).
|
||||
"""
|
||||
channel = _state.channel
|
||||
|
||||
def _run(cmd: List[str], env: Optional[dict] = None) -> bool:
|
||||
"""Run command, streaming lines into progress_lines. Returns success bool."""
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=env,
|
||||
)
|
||||
for line in proc.stdout:
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
_state.append_line(line)
|
||||
logger.debug(f"[pip] {line}")
|
||||
proc.wait()
|
||||
return proc.returncode == 0
|
||||
except Exception as exc:
|
||||
_state.append_line(f"[error] {exc}")
|
||||
logger.error(f"[Update] Subprocess error: {exc}")
|
||||
return False
|
||||
|
||||
import os as _os
|
||||
env = _os.environ.copy()
|
||||
env["PIP_ROOT_USER_ACTION"] = "ignore"
|
||||
env["SETUPTOOLS_SCM_PRETEND_VERSION"] = _state.latest_version or "1.0.0"
|
||||
|
||||
_state.append_line(f"[pyMC updater] Installing from channel '{channel}'…")
|
||||
|
||||
_UPGRADE_WRAPPER = "/usr/local/bin/pymc-do-upgrade"
|
||||
is_root = (_os.geteuid() == 0)
|
||||
|
||||
if is_root:
|
||||
install_spec = (
|
||||
f"git+https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}.git@{channel}[hardware]"
|
||||
)
|
||||
_state.append_line(f"[pyMC updater] Running as root – direct pip install")
|
||||
_state.append_line(f"[pyMC updater] Target: {install_spec}")
|
||||
cmd = [
|
||||
"python3", "-m", "pip", "install",
|
||||
"--break-system-packages",
|
||||
"--no-cache-dir",
|
||||
"--force-reinstall",
|
||||
install_spec,
|
||||
]
|
||||
elif _os.path.isfile(_UPGRADE_WRAPPER):
|
||||
_state.append_line(f"[pyMC updater] Using sudo wrapper: {_UPGRADE_WRAPPER}")
|
||||
cmd = ["sudo", _UPGRADE_WRAPPER, channel]
|
||||
else:
|
||||
msg = (
|
||||
f"Upgrade wrapper not found at {_UPGRADE_WRAPPER}. "
|
||||
"Re-run manage.sh install/upgrade to configure sudo permissions."
|
||||
)
|
||||
_state.finish_install(False, msg)
|
||||
return
|
||||
|
||||
success = _run(cmd, env=env)
|
||||
|
||||
if success:
|
||||
_state.finish_install(True, f"Upgraded to latest on channel '{channel}'")
|
||||
_state.append_line("[pyMC updater] Restarting service in 3 seconds…")
|
||||
time.sleep(3)
|
||||
try:
|
||||
from repeater.service_utils import restart_service
|
||||
ok, msg = restart_service()
|
||||
logger.info(f"[Update] Post-upgrade restart: {msg}")
|
||||
except Exception as exc:
|
||||
logger.warning(f"[Update] Could not restart service: {exc}")
|
||||
else:
|
||||
_state.finish_install(False, "pip install failed – see progress log for details")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CherryPy Endpoint class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class UpdateAPIEndpoints:
|
||||
"""
|
||||
Mounted at /api/update/ inside APIEndpoints.
|
||||
All mutating endpoints require an authenticated user (Bearer JWT or API token).
|
||||
"""
|
||||
|
||||
def _set_cors_headers(self, config: dict) -> None:
|
||||
if config.get("web", {}).get("cors_enabled", False):
|
||||
cherrypy.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
cherrypy.response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||
cherrypy.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
||||
|
||||
def _require_post(self):
|
||||
if cherrypy.request.method != "POST":
|
||||
raise cherrypy.HTTPError(405, "Method Not Allowed")
|
||||
|
||||
@staticmethod
|
||||
def _ok(data: dict) -> dict:
|
||||
return {"success": True, **data}
|
||||
|
||||
@staticmethod
|
||||
def _err(msg: str, status: int = 400) -> dict:
|
||||
cherrypy.response.status = status
|
||||
return {"success": False, "error": str(msg)}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# GET /api/update/status #
|
||||
# ------------------------------------------------------------------ #
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def status(self, **kwargs):
|
||||
"""
|
||||
Return current update status without triggering a fresh check.
|
||||
|
||||
Response:
|
||||
{success, current_version, latest_version, has_update,
|
||||
channel, last_checked, state, error}
|
||||
"""
|
||||
if cherrypy.request.method == "OPTIONS":
|
||||
return ""
|
||||
|
||||
snap = _state.snapshot()
|
||||
return self._ok(snap)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/update/check #
|
||||
# ------------------------------------------------------------------ #
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def check(self, **kwargs):
|
||||
"""
|
||||
Force a fresh version check against GitHub. Non-blocking – spawns a
|
||||
background thread; poll /api/update/status for the result.
|
||||
|
||||
Response:
|
||||
{success, message, state}
|
||||
"""
|
||||
if cherrypy.request.method == "OPTIONS":
|
||||
return ""
|
||||
|
||||
if cherrypy.request.method not in ("POST", "GET"):
|
||||
raise cherrypy.HTTPError(405)
|
||||
|
||||
# Honour the cache to avoid hammering GitHub
|
||||
snap = _state.snapshot()
|
||||
if snap["state"] == "checking":
|
||||
return self._ok({"message": "Check already in progress", "state": "checking"})
|
||||
|
||||
if snap["last_checked"] is not None:
|
||||
age = (datetime.utcnow() - _state.last_checked).total_seconds()
|
||||
if age < CHECK_CACHE_TTL and snap["latest_version"] is not None:
|
||||
return self._ok({"message": "Using cached result", "state": snap["state"], **snap})
|
||||
|
||||
if not _state._set_checking():
|
||||
return self._ok({"message": "Busy – try again shortly", "state": _state.state})
|
||||
|
||||
t = threading.Thread(target=_do_check, daemon=True, name="update-check")
|
||||
t.start()
|
||||
|
||||
logger.info("[Update] Version check initiated via API")
|
||||
return self._ok({"message": "Update check started", "state": "checking"})
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/update/install #
|
||||
# ------------------------------------------------------------------ #
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@cherrypy.tools.json_in()
|
||||
def install(self, **kwargs):
|
||||
"""
|
||||
Start the upgrade process in a background thread.
|
||||
|
||||
The caller should open the SSE stream at /api/update/progress to
|
||||
watch live output. The service will restart automatically on success.
|
||||
|
||||
Optional JSON body:
|
||||
{"force": true} – install even if no update is detected
|
||||
|
||||
Response:
|
||||
{success, message, state}
|
||||
"""
|
||||
if cherrypy.request.method == "OPTIONS":
|
||||
return ""
|
||||
|
||||
try:
|
||||
self._require_post()
|
||||
except cherrypy.HTTPError:
|
||||
raise
|
||||
|
||||
body = {}
|
||||
try:
|
||||
body = cherrypy.request.json or {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
snap = _state.snapshot()
|
||||
|
||||
if snap["state"] == "installing":
|
||||
return self._err("An update is already in progress", 409)
|
||||
|
||||
force = bool(body.get("force", False))
|
||||
if not force and not snap["has_update"]:
|
||||
# Still allow install if no check has been done yet
|
||||
if snap["latest_version"] is not None:
|
||||
return self._err(
|
||||
f"Already up to date ({snap['current_version']}). "
|
||||
"Pass {\"force\": true} to reinstall anyway.",
|
||||
409,
|
||||
)
|
||||
|
||||
t = threading.Thread(target=_do_install, daemon=True, name="update-install")
|
||||
started = _state.start_install(t)
|
||||
if not started:
|
||||
return self._err("Could not start install thread – check state", 409)
|
||||
|
||||
t.start()
|
||||
logger.warning(
|
||||
f"[Update] Install triggered via API – channel={_state.channel}"
|
||||
)
|
||||
return self._ok({
|
||||
"message": f"Update started on channel '{_state.channel}'. "
|
||||
"Watch /api/update/progress for live output.",
|
||||
"state": "installing",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# GET /api/update/progress (SSE) #
|
||||
# ------------------------------------------------------------------ #
|
||||
@cherrypy.expose
|
||||
def progress(self, **kwargs):
|
||||
"""
|
||||
Server-Sent Events stream that emits install log lines in real time.
|
||||
|
||||
Event types:
|
||||
connected – initial handshake
|
||||
line – one log line {line: str}
|
||||
status – state change {state: str}
|
||||
keepalive – heartbeat (every 5 s)
|
||||
done – stream finished {state: str, error: str|null}
|
||||
"""
|
||||
cherrypy.response.headers["Content-Type"] = "text/event-stream"
|
||||
cherrypy.response.headers["Cache-Control"] = "no-cache"
|
||||
cherrypy.response.headers["X-Accel-Buffering"] = "no"
|
||||
cherrypy.response.headers["Connection"] = "keep-alive"
|
||||
|
||||
def generate():
|
||||
yield f"data: {json.dumps({'type': 'connected', 'message': 'Connected to update progress stream'})}\n\n"
|
||||
|
||||
last_idx = 0
|
||||
last_state = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
snap = _state.snapshot()
|
||||
current_state = snap["state"]
|
||||
|
||||
# Emit any new log lines
|
||||
current_lines = _state.progress_lines
|
||||
new_lines = current_lines[last_idx:]
|
||||
for line in new_lines:
|
||||
payload = json.dumps({"type": "line", "line": line})
|
||||
yield f"data: {payload}\n\n"
|
||||
last_idx = len(current_lines)
|
||||
|
||||
# Emit state transitions
|
||||
if current_state != last_state:
|
||||
payload = json.dumps({"type": "status", "state": current_state})
|
||||
yield f"data: {payload}\n\n"
|
||||
last_state = current_state
|
||||
|
||||
# Terminate stream when install completes or errors
|
||||
if current_state in ("complete", "error") and last_idx >= len(current_lines):
|
||||
done_payload = json.dumps({
|
||||
"type": "done",
|
||||
"state": current_state,
|
||||
"error": snap.get("error"),
|
||||
})
|
||||
yield f"data: {done_payload}\n\n"
|
||||
return
|
||||
|
||||
# Keepalive
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
time.sleep(1.5)
|
||||
|
||||
except GeneratorExit:
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.debug(f"[Update SSE] stream error: {exc}")
|
||||
return
|
||||
|
||||
return generate()
|
||||
|
||||
progress._cp_config = {"response.stream": True}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# GET /api/update/channels #
|
||||
# ------------------------------------------------------------------ #
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def channels(self, **kwargs):
|
||||
"""
|
||||
Return available GitHub branches as selectable channels.
|
||||
|
||||
Response:
|
||||
{success, channels: [str], current_channel: str}
|
||||
"""
|
||||
if cherrypy.request.method == "OPTIONS":
|
||||
return ""
|
||||
|
||||
branch_list = _fetch_branches()
|
||||
return self._ok({
|
||||
"channels": branch_list,
|
||||
"current_channel": _state.channel,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/update/set_channel #
|
||||
# ------------------------------------------------------------------ #
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@cherrypy.tools.json_in()
|
||||
def set_channel(self, **kwargs):
|
||||
"""
|
||||
Switch the release channel (branch) used for future update checks and installs.
|
||||
|
||||
JSON body:
|
||||
{"channel": "dev"}
|
||||
|
||||
Response:
|
||||
{success, channel, message}
|
||||
"""
|
||||
if cherrypy.request.method == "OPTIONS":
|
||||
return ""
|
||||
|
||||
try:
|
||||
self._require_post()
|
||||
except cherrypy.HTTPError:
|
||||
raise
|
||||
|
||||
body = {}
|
||||
try:
|
||||
body = cherrypy.request.json or {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
channel = str(body.get("channel", "")).strip()
|
||||
if not channel:
|
||||
return self._err("'channel' field is required")
|
||||
|
||||
if _state.state == "installing":
|
||||
return self._err("Cannot change channel while an install is in progress", 409)
|
||||
|
||||
_state.set_channel(channel)
|
||||
logger.info(f"[Update] Channel changed to '{channel}' via API")
|
||||
return self._ok({
|
||||
"channel": channel,
|
||||
"message": f"Channel switched to '{channel}'. Run /api/update/check to verify.",
|
||||
})
|
||||
Reference in New Issue
Block a user