mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Replace SpoolBuddy self-update with SSH-based updates from Bambuddy
The daemon's self-update mechanism (git fetch/reset on its own code) was fragile: .git permission errors, self-modifying code mid-run, hardcoded main branch. Bambuddy now SSHes into the SpoolBuddy Pi and drives the update remotely — matching its own branch, with step-by-step progress via WebSocket. Install script updated with SSH access, sudoers entry, and --ssh-pubkey flag for pairing.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""SpoolBuddy device management API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -597,10 +598,17 @@ async def check_daemon_update(
|
||||
@router.post("/devices/{device_id}/update")
|
||||
async def trigger_daemon_update(
|
||||
device_id: str,
|
||||
req: dict | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Trigger a daemon update on the SpoolBuddy device via pending_command."""
|
||||
"""Trigger a SpoolBuddy update over SSH.
|
||||
|
||||
Bambuddy SSHes into the device, pulls the matching branch, installs deps,
|
||||
and restarts the daemon. Progress is broadcast via WebSocket.
|
||||
"""
|
||||
from backend.app.services.spoolbuddy_ssh import perform_ssh_update
|
||||
|
||||
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
|
||||
device = result.scalar_one_or_none()
|
||||
if not device:
|
||||
@@ -612,12 +620,11 @@ async def trigger_daemon_update(
|
||||
if device.update_status == "updating":
|
||||
return {"status": "already_updating", "message": "Update already in progress"}
|
||||
|
||||
device.pending_command = "update"
|
||||
device.update_status = "pending"
|
||||
device.update_message = "Waiting for device to pick up update command..."
|
||||
device.update_message = "Starting SSH update..."
|
||||
await db.commit()
|
||||
|
||||
logger.info("SpoolBuddy %s: update command queued", device_id)
|
||||
logger.info("SpoolBuddy %s: SSH update triggered (ip=%s)", device_id, device.ip_address)
|
||||
await ws_manager.broadcast(
|
||||
{
|
||||
"type": "spoolbuddy_update",
|
||||
@@ -626,7 +633,24 @@ async def trigger_daemon_update(
|
||||
}
|
||||
)
|
||||
|
||||
return {"status": "ok", "message": "Update command sent to device"}
|
||||
# Run the SSH update in the background
|
||||
asyncio.create_task(perform_ssh_update(device_id, device.ip_address))
|
||||
|
||||
return {"status": "ok", "message": "SSH update started"}
|
||||
|
||||
|
||||
@router.get("/ssh/public-key")
|
||||
async def get_ssh_public_key(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Return the SSH public key for SpoolBuddy pairing."""
|
||||
from backend.app.services.spoolbuddy_ssh import get_public_key
|
||||
|
||||
try:
|
||||
key = await get_public_key()
|
||||
return {"public_key": key}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get SSH key: {e}") from e
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/update-status")
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""SSH-based update service for SpoolBuddy devices.
|
||||
|
||||
Instead of the daemon updating itself (fragile: permission issues, self-modifying
|
||||
code, hardcoded branch), Bambuddy SSHes into the SpoolBuddy Pi and drives the
|
||||
update remotely: git fetch/checkout, pip install, systemctl restart.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SSH_USER = "spoolbuddy"
|
||||
DEFAULT_INSTALL_PATH = "/opt/bambuddy"
|
||||
|
||||
|
||||
def _get_ssh_key_dir() -> Path:
|
||||
"""Return (and create if needed) the directory for SpoolBuddy SSH keys."""
|
||||
key_dir = settings.base_dir / "spoolbuddy" / "ssh"
|
||||
if not key_dir.exists():
|
||||
key_dir.mkdir(mode=0o700, parents=True)
|
||||
return key_dir
|
||||
|
||||
|
||||
async def get_or_create_keypair() -> tuple[Path, Path]:
|
||||
"""Return (private_key_path, public_key_path), generating if missing."""
|
||||
key_dir = _get_ssh_key_dir()
|
||||
private_key = key_dir / "id_ed25519"
|
||||
public_key = key_dir / "id_ed25519.pub"
|
||||
|
||||
if private_key.exists() and public_key.exists():
|
||||
return private_key, public_key
|
||||
|
||||
logger.info("Generating SSH keypair for SpoolBuddy updates")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ssh-keygen",
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-f",
|
||||
str(private_key),
|
||||
"-N",
|
||||
"", # no passphrase
|
||||
"-C",
|
||||
"bambuddy-spoolbuddy",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"ssh-keygen failed: {stderr.decode()[:200]}")
|
||||
|
||||
private_key.chmod(0o600)
|
||||
logger.info("SSH keypair generated at %s", key_dir)
|
||||
return private_key, public_key
|
||||
|
||||
|
||||
async def get_public_key() -> str:
|
||||
"""Return the SSH public key content for pairing."""
|
||||
_, public_key = await get_or_create_keypair()
|
||||
return public_key.read_text().strip()
|
||||
|
||||
|
||||
def detect_current_branch() -> str:
|
||||
"""Detect the git branch Bambuddy is running on.
|
||||
|
||||
For native installs, reads from the .git directory.
|
||||
For Docker (no .git), falls back to GIT_BRANCH env var, then "main".
|
||||
"""
|
||||
git_dir = settings.base_dir / ".git"
|
||||
if git_dir.exists():
|
||||
git_path = shutil.which("git") or "/usr/bin/git"
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=str(settings.base_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return os.environ.get("GIT_BRANCH", "main")
|
||||
|
||||
|
||||
async def _run_ssh_command(
|
||||
ip: str,
|
||||
command: str,
|
||||
private_key: Path,
|
||||
timeout: int = 60,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Execute a command on a SpoolBuddy device via SSH.
|
||||
|
||||
Returns (returncode, stdout, stderr).
|
||||
"""
|
||||
ssh_path = shutil.which("ssh") or "/usr/bin/ssh"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ssh_path,
|
||||
"-i",
|
||||
str(private_key),
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"LogLevel=ERROR",
|
||||
f"{SSH_USER}@{ip}",
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except TimeoutError:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
return -1, "", "SSH command timed out"
|
||||
|
||||
return proc.returncode, stdout.decode(), stderr.decode()
|
||||
|
||||
|
||||
async def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None:
|
||||
"""SSH into a SpoolBuddy device and update it to match Bambuddy's branch.
|
||||
|
||||
Updates device.update_status/update_message in the DB and broadcasts
|
||||
progress via WebSocket at each step.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.api.routes.spoolbuddy import ws_manager
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
|
||||
|
||||
install_path = install_path or DEFAULT_INSTALL_PATH
|
||||
branch = detect_current_branch()
|
||||
|
||||
async def _update_progress(status: str, message: str) -> None:
|
||||
"""Update device status in DB and broadcast via WebSocket."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
|
||||
device = result.scalar_one_or_none()
|
||||
if device:
|
||||
device.update_status = status
|
||||
device.update_message = message[:255] if message else None
|
||||
if status in ("complete", "error"):
|
||||
device.pending_command = None
|
||||
await db.commit()
|
||||
|
||||
await ws_manager.broadcast(
|
||||
{
|
||||
"type": "spoolbuddy_update",
|
||||
"device_id": device_id,
|
||||
"update_status": status,
|
||||
"update_message": message[:255] if message else None,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
private_key, _ = await get_or_create_keypair()
|
||||
|
||||
# Step 1: Test SSH connectivity
|
||||
await _update_progress("updating", "Connecting via SSH...")
|
||||
rc, _, stderr = await _run_ssh_command(ip_address, "echo ok", private_key)
|
||||
if rc != 0:
|
||||
await _update_progress("error", f"SSH connection failed: {stderr[:200]}")
|
||||
return
|
||||
|
||||
# Step 2: Git fetch
|
||||
await _update_progress("updating", f"Fetching latest code (branch: {branch})...")
|
||||
rc, _, stderr = await _run_ssh_command(
|
||||
ip_address,
|
||||
f"cd {install_path} && git -c safe.directory={install_path} fetch origin {branch}",
|
||||
private_key,
|
||||
timeout=120,
|
||||
)
|
||||
if rc != 0:
|
||||
await _update_progress("error", f"git fetch failed: {stderr[:200]}")
|
||||
return
|
||||
|
||||
# Step 3: Git checkout + reset
|
||||
await _update_progress("updating", "Applying update...")
|
||||
rc, _, stderr = await _run_ssh_command(
|
||||
ip_address,
|
||||
f"cd {install_path} && git -c safe.directory={install_path} checkout {branch} "
|
||||
f"&& git -c safe.directory={install_path} reset --hard origin/{branch}",
|
||||
private_key,
|
||||
)
|
||||
if rc != 0:
|
||||
await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}")
|
||||
return
|
||||
|
||||
# Step 4: Install dependencies
|
||||
await _update_progress("updating", "Installing dependencies...")
|
||||
venv_pip = f"{install_path}/spoolbuddy/venv/bin/pip"
|
||||
rc, _, stderr = await _run_ssh_command(
|
||||
ip_address,
|
||||
f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
|
||||
private_key,
|
||||
timeout=120,
|
||||
)
|
||||
if rc != 0:
|
||||
logger.warning("SpoolBuddy %s: pip install returned non-zero (continuing): %s", device_id, stderr[:200])
|
||||
|
||||
# Step 5: Restart service
|
||||
await _update_progress("updating", "Restarting daemon...")
|
||||
rc, _, stderr = await _run_ssh_command(
|
||||
ip_address,
|
||||
"sudo /usr/bin/systemctl restart spoolbuddy.service",
|
||||
private_key,
|
||||
)
|
||||
if rc != 0:
|
||||
await _update_progress("error", f"Service restart failed: {stderr[:200]}")
|
||||
return
|
||||
|
||||
await _update_progress("complete", "Update complete, daemon restarting...")
|
||||
logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, branch)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("SpoolBuddy %s: SSH update failed: %s", device_id, e)
|
||||
await _update_progress("error", f"Update failed: {str(e)[:200]}")
|
||||
@@ -813,27 +813,19 @@ class TestDisplayEndpoints:
|
||||
class TestUpdateEndpoints:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_trigger_update_queues_command(self, async_client: AsyncClient, device_factory):
|
||||
async def test_trigger_update_starts_ssh_update(self, async_client: AsyncClient, device_factory):
|
||||
await device_factory(device_id="sb-upd")
|
||||
|
||||
with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
|
||||
with (
|
||||
patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
|
||||
patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
|
||||
):
|
||||
mock_ws.broadcast = AsyncMock()
|
||||
resp = await async_client.post(f"{API}/devices/sb-upd/update")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
# Verify heartbeat returns update command
|
||||
with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
|
||||
mock_ws.broadcast = AsyncMock()
|
||||
hb = await async_client.post(
|
||||
f"{API}/devices/sb-upd/heartbeat",
|
||||
json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
|
||||
)
|
||||
|
||||
# update command is NOT cleared by heartbeat (cleared by update-status)
|
||||
assert hb.json()["pending_command"] == "update"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
|
||||
@@ -943,50 +935,25 @@ class TestUpdateEndpoints:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
|
||||
"""GET /devices/{id}/update-check queries GitHub and returns version comparison."""
|
||||
"""GET /devices/{id}/update-check compares device version against APP_VERSION."""
|
||||
await device_factory(device_id="sb-uc", firmware_version="0.1.0")
|
||||
|
||||
mock_releases = [{"tag_name": "v0.2.0", "html_url": "https://github.com/test/releases/0.2.0"}]
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = mock_releases
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_resp
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
|
||||
resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["current_version"] == "0.1.0"
|
||||
assert data["latest_version"] == "0.2.0"
|
||||
assert data["latest_version"] is not None
|
||||
assert data["update_available"] is True
|
||||
assert data["release_url"] == "https://github.com/test/releases/0.2.0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
|
||||
await device_factory(device_id="sb-uc2", firmware_version="0.2.0")
|
||||
from backend.app.core.config import APP_VERSION
|
||||
|
||||
mock_releases = [{"tag_name": "v0.2.0", "html_url": "https://github.com/test/releases/0.2.0"}]
|
||||
await device_factory(device_id="sb-uc2", firmware_version=APP_VERSION)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = mock_releases
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_resp
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
|
||||
resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["update_available"] is False
|
||||
@@ -1002,7 +969,10 @@ class TestUpdateEndpoints:
|
||||
async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
|
||||
await device_factory(device_id="sb-upd-ws")
|
||||
|
||||
with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
|
||||
with (
|
||||
patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
|
||||
patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
|
||||
):
|
||||
mock_ws.broadcast = AsyncMock()
|
||||
await async_client.post(f"{API}/devices/sb-upd-ws/update")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user