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

+25 -9
View File
@@ -18,6 +18,8 @@ RECORD = record(10001, "", 1000, 10008)
class Server(paramiko.ServerInterface):
reject_exec = False
def __init__(self, allowed: paramiko.PKey):
self.allowed = allowed
self.commands = []
@@ -34,6 +36,8 @@ class Server(paramiko.ServerInterface):
def check_channel_exec_request(self, channel, command):
self.commands.append(command)
if Server.reject_exec:
return False
def reply(): # forced command: always the canvas record
channel.sendall(json.dumps(RECORD).encode())
@@ -102,13 +106,13 @@ def test_rejects_garbage_key(tmp_path):
def test_first_contact_requires_confirmation(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
with pytest.raises(HostKeyUnknown):
reader.read()
result = reader.test()
assert result["host_key_pinned"] is False
assert result["record"]["channels0"]["weight"] == "10001"
keys.pin_host_key("127.0.0.1", printer["host_key"])
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
rec = reader.read()
assert rec["channels3"]["weight"] == "10008"
# transport is reused between reads
@@ -121,8 +125,8 @@ def test_changed_host_key_is_rejected(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
other = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text()))
keys.pin_host_key("127.0.0.1", other)
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
keys.pin_host_key("p1", "127.0.0.1", other)
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
with pytest.raises(HostKeyMismatch):
reader.read()
with pytest.raises(HostKeyMismatch):
@@ -132,16 +136,16 @@ def test_changed_host_key_is_rejected(tmp_path, printer):
def test_wrong_client_key_fails_cleanly(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(_new_key_text())
keys.pin_host_key("127.0.0.1", printer["host_key"])
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
with pytest.raises(SSHError, match="abgelehnt|fehlgeschlagen"):
CanvasRecordReader(keys, "127.0.0.1", printer["port"]).read()
CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"]).read()
def test_reconnects_after_drop(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
keys.pin_host_key("127.0.0.1", printer["host_key"])
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
reader.read()
for t, _ in printer["servers"]:
t.close()
@@ -156,4 +160,16 @@ def test_unreachable_printer(tmp_path, printer):
port = s.getsockname()[1]
s.close()
with pytest.raises(SSHError, match="nicht erreichbar"):
CanvasRecordReader(keys, "127.0.0.1", port, timeout=2).read()
CanvasRecordReader(keys, "p1", "127.0.0.1", port, timeout=2).read()
def test_exec_failure_is_a_clean_error(tmp_path, printer, monkeypatch):
monkeypatch.setattr(Server, "reject_exec", True)
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
with pytest.raises(SSHError, match="Lesefehler"):
reader.test()
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
with pytest.raises(SSHError, match="Lesefehler"):
reader.read()