Multiple printers, printer/slot in Spoolman, kept in sync
- 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:
1 parent
bf32667f77
commit
a9041eb538
15 files changed
+1400
-684
No files matched your search
+22
-11
@@ -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)},
|
||||
|
||||
Reference in new issue
Block a user