| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- """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 daemon
- 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
- # Step 6: Restart kiosk browser to load updated frontend
- rc, _, stderr = await _run_ssh_command(
- ip_address,
- "sudo /usr/bin/systemctl restart getty@tty1.service",
- private_key,
- )
- if rc != 0:
- # Non-fatal — kiosk may not be set up on all devices
- logger.warning("SpoolBuddy %s: kiosk restart failed (non-fatal): %s", device_id, stderr[:200])
- 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]}")
|