spoolbuddy_ssh.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """SSH-based update service for SpoolBuddy devices.
  2. Instead of the daemon updating itself (fragile: permission issues, self-modifying
  3. code, hardcoded branch), Bambuddy SSHes into the SpoolBuddy Pi and drives the
  4. update remotely: git fetch/checkout, pip install, systemctl restart.
  5. """
  6. import asyncio
  7. import logging
  8. import os
  9. import shutil
  10. from pathlib import Path
  11. from backend.app.core.config import settings
  12. logger = logging.getLogger(__name__)
  13. SSH_USER = "spoolbuddy"
  14. DEFAULT_INSTALL_PATH = "/opt/bambuddy"
  15. def _get_ssh_key_dir() -> Path:
  16. """Return (and create if needed) the directory for SpoolBuddy SSH keys."""
  17. key_dir = settings.base_dir / "spoolbuddy" / "ssh"
  18. if not key_dir.exists():
  19. key_dir.mkdir(mode=0o700, parents=True)
  20. return key_dir
  21. async def get_or_create_keypair() -> tuple[Path, Path]:
  22. """Return (private_key_path, public_key_path), generating if missing."""
  23. key_dir = _get_ssh_key_dir()
  24. private_key = key_dir / "id_ed25519"
  25. public_key = key_dir / "id_ed25519.pub"
  26. if private_key.exists() and public_key.exists():
  27. return private_key, public_key
  28. logger.info("Generating SSH keypair for SpoolBuddy updates")
  29. proc = await asyncio.create_subprocess_exec(
  30. "ssh-keygen",
  31. "-t",
  32. "ed25519",
  33. "-f",
  34. str(private_key),
  35. "-N",
  36. "", # no passphrase
  37. "-C",
  38. "bambuddy-spoolbuddy",
  39. stdout=asyncio.subprocess.PIPE,
  40. stderr=asyncio.subprocess.PIPE,
  41. )
  42. _, stderr = await proc.communicate()
  43. if proc.returncode != 0:
  44. raise RuntimeError(f"ssh-keygen failed: {stderr.decode()[:200]}")
  45. private_key.chmod(0o600)
  46. logger.info("SSH keypair generated at %s", key_dir)
  47. return private_key, public_key
  48. async def get_public_key() -> str:
  49. """Return the SSH public key content for pairing."""
  50. _, public_key = await get_or_create_keypair()
  51. return public_key.read_text().strip()
  52. def detect_current_branch() -> str:
  53. """Detect the git branch Bambuddy is running on.
  54. For native installs, reads from the .git directory.
  55. For Docker (no .git), falls back to GIT_BRANCH env var, then "main".
  56. """
  57. git_dir = settings.base_dir / ".git"
  58. if git_dir.exists():
  59. git_path = shutil.which("git") or "/usr/bin/git"
  60. try:
  61. import subprocess
  62. result = subprocess.run(
  63. [git_path, "rev-parse", "--abbrev-ref", "HEAD"],
  64. cwd=str(settings.base_dir),
  65. capture_output=True,
  66. text=True,
  67. timeout=5,
  68. )
  69. if result.returncode == 0 and result.stdout.strip():
  70. return result.stdout.strip()
  71. except Exception:
  72. pass
  73. return os.environ.get("GIT_BRANCH", "main")
  74. async def _run_ssh_command(
  75. ip: str,
  76. command: str,
  77. private_key: Path,
  78. timeout: int = 60,
  79. ) -> tuple[int, str, str]:
  80. """Execute a command on a SpoolBuddy device via SSH.
  81. Returns (returncode, stdout, stderr).
  82. """
  83. ssh_path = shutil.which("ssh") or "/usr/bin/ssh"
  84. proc = await asyncio.create_subprocess_exec(
  85. ssh_path,
  86. "-i",
  87. str(private_key),
  88. "-o",
  89. "StrictHostKeyChecking=no",
  90. "-o",
  91. "UserKnownHostsFile=/dev/null",
  92. "-o",
  93. "ConnectTimeout=10",
  94. "-o",
  95. "BatchMode=yes",
  96. "-o",
  97. "LogLevel=ERROR",
  98. f"{SSH_USER}@{ip}",
  99. command,
  100. stdout=asyncio.subprocess.PIPE,
  101. stderr=asyncio.subprocess.PIPE,
  102. )
  103. try:
  104. stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
  105. except TimeoutError:
  106. proc.kill()
  107. await proc.communicate()
  108. return -1, "", "SSH command timed out"
  109. return proc.returncode, stdout.decode(), stderr.decode()
  110. async def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None:
  111. """SSH into a SpoolBuddy device and update it to match Bambuddy's branch.
  112. Updates device.update_status/update_message in the DB and broadcasts
  113. progress via WebSocket at each step.
  114. """
  115. from sqlalchemy import select
  116. from backend.app.api.routes.spoolbuddy import ws_manager
  117. from backend.app.core.database import async_session
  118. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  119. install_path = install_path or DEFAULT_INSTALL_PATH
  120. branch = detect_current_branch()
  121. async def _update_progress(status: str, message: str) -> None:
  122. """Update device status in DB and broadcast via WebSocket."""
  123. async with async_session() as db:
  124. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  125. device = result.scalar_one_or_none()
  126. if device:
  127. device.update_status = status
  128. device.update_message = message[:255] if message else None
  129. if status in ("complete", "error"):
  130. device.pending_command = None
  131. await db.commit()
  132. await ws_manager.broadcast(
  133. {
  134. "type": "spoolbuddy_update",
  135. "device_id": device_id,
  136. "update_status": status,
  137. "update_message": message[:255] if message else None,
  138. }
  139. )
  140. try:
  141. private_key, _ = await get_or_create_keypair()
  142. # Step 1: Test SSH connectivity
  143. await _update_progress("updating", "Connecting via SSH...")
  144. rc, _, stderr = await _run_ssh_command(ip_address, "echo ok", private_key)
  145. if rc != 0:
  146. await _update_progress("error", f"SSH connection failed: {stderr[:200]}")
  147. return
  148. # Step 2: Git fetch
  149. await _update_progress("updating", f"Fetching latest code (branch: {branch})...")
  150. rc, _, stderr = await _run_ssh_command(
  151. ip_address,
  152. f"cd {install_path} && git -c safe.directory={install_path} fetch origin {branch}",
  153. private_key,
  154. timeout=120,
  155. )
  156. if rc != 0:
  157. await _update_progress("error", f"git fetch failed: {stderr[:200]}")
  158. return
  159. # Step 3: Git checkout + reset
  160. await _update_progress("updating", "Applying update...")
  161. rc, _, stderr = await _run_ssh_command(
  162. ip_address,
  163. f"cd {install_path} && git -c safe.directory={install_path} checkout {branch} "
  164. f"&& git -c safe.directory={install_path} reset --hard origin/{branch}",
  165. private_key,
  166. )
  167. if rc != 0:
  168. await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}")
  169. return
  170. # Step 4: Install dependencies
  171. await _update_progress("updating", "Installing dependencies...")
  172. venv_pip = f"{install_path}/spoolbuddy/venv/bin/pip"
  173. rc, _, stderr = await _run_ssh_command(
  174. ip_address,
  175. f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
  176. private_key,
  177. timeout=120,
  178. )
  179. if rc != 0:
  180. logger.warning("SpoolBuddy %s: pip install returned non-zero (continuing): %s", device_id, stderr[:200])
  181. # Step 5: Restart daemon
  182. await _update_progress("updating", "Restarting daemon...")
  183. rc, _, stderr = await _run_ssh_command(
  184. ip_address,
  185. "sudo /usr/bin/systemctl restart spoolbuddy.service",
  186. private_key,
  187. )
  188. if rc != 0:
  189. await _update_progress("error", f"Service restart failed: {stderr[:200]}")
  190. return
  191. # No explicit kiosk restart — the frontend detects daemon re-registration
  192. # via WebSocket and reloads itself automatically.
  193. logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, branch)
  194. except Exception as e:
  195. logger.error("SpoolBuddy %s: SSH update failed: %s", device_id, e)
  196. await _update_progress("error", f"Update failed: {str(e)[:200]}")