Multiple printers, printer/slot in Spoolman, kept in sync
CI / test (push) Successful in 1m6s
CI / image (push) Successful in 43s

- One worker per printer (name, IP, CC2 Control URL, own pinned host key);
  a spool sits in at most one slot of one printer, moves between printers
- Spoolman: location "<printer> Slot N" plus extra fields "drucker"/"slot"
  (created by the bridge), cleared and "Lager" on removal
- Full comparison every minute: the printer wins over manual edits of loaded
  spools; stale printer locations go to storage; own locations untouched
- Automatic migration of v1 settings, state and host key
- Status hint when a tag is still in a tray but unmapped (e.g. after
  "Take out" in CC2 Control)
- SSH test: connection drops during exec give a clean error, not a 500
- Mock environment with several printers; tests for all of the above

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 03:53:01 +01:00
1 parent bf32667f77
commit a9041eb538
15 files changed
+1400 -684

No files matched your search

+22 -11
View File
@@ -108,25 +108,36 @@ class KeyStore:
"public": f"{key.get_name()} {key.get_base64()} cc2-spoolman-bridge",
}
def host_key(self) -> dict | None:
return read_json(self.host_path, None)
def _host_path(self, pid: str) -> Path:
if not pid.replace("-", "").replace("_", "").isalnum():
raise ValueError("invalid printer id")
return self.dir / f"host_key_{pid}.json"
def pin_host_key(self, host: str, key: paramiko.PKey) -> None:
def migrate_host_key(self, pid: str) -> None:
"""v1 kept one host key in host_key.json; it belongs to the first printer."""
if self.host_path.exists() and not self._host_path(pid).exists():
os.replace(self.host_path, self._host_path(pid))
def host_key(self, pid: str) -> dict | None:
return read_json(self._host_path(pid), None)
def pin_host_key(self, pid: str, host: str, key: paramiko.PKey) -> None:
self.dir.mkdir(parents=True, exist_ok=True)
atomic_write_json(self.host_path, {
atomic_write_json(self._host_path(pid), {
"host": host, "type": key.get_name(), "key": key.get_base64(), "fingerprint": fingerprint(key),
}, mode=0o600)
def forget_host_key(self) -> None:
def forget_host_key(self, pid: str) -> None:
try:
self.host_path.unlink()
self._host_path(pid).unlink()
except FileNotFoundError:
pass
class CanvasRecordReader:
def __init__(self, keys: KeyStore, host: str, port: int = 22, user: str = "root", timeout: float = 10.0):
def __init__(self, keys: KeyStore, pid: str, host: str, port: int = 22, user: str = "root", timeout: float = 10.0):
self.keys = keys
self.pid = pid
self.host, self.port, self.user, self.timeout = host, port, user, timeout
self._transport: paramiko.Transport | None = None
self._lock = threading.Lock()
@@ -155,7 +166,7 @@ class CanvasRecordReader:
try:
t.start_client(timeout=self.timeout)
server_key = t.get_remote_server_key()
pinned = self.keys.host_key()
pinned = self.keys.host_key(self.pid)
if pinned is None:
if not accept_unknown:
raise HostKeyUnknown(server_key)
@@ -222,11 +233,11 @@ class CanvasRecordReader:
try:
server_key = self._connect(accept_unknown=True)
raw = self._exec()
except HostKeyMismatch:
raise
except (paramiko.SSHException, OSError, EOFError) as e:
raise SSHError(f"SSH-Lesefehler ({type(e).__name__})") from None
finally:
self._close()
pinned = self.keys.host_key()
pinned = self.keys.host_key(self.pid)
return {
"host_key": {"type": server_key.get_name(), "key": server_key.get_base64(),
"fingerprint": fingerprint(server_key)},