From cd0057c129f13fc978f8ff7a219285d249bad584 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 18 Apr 2026 19:10:25 -0400 Subject: [PATCH] feat(swarm): DeckyConfig.host_uuid + fix agent log/status field refs - decnet.models.DeckyConfig grows an optional 'host_uuid' (the SwarmHost that runs this decky). Defaults to None so legacy unihost state files deserialize unchanged. - decnet.agent.executor: replace non-existent config.name references with config.mode / config.interface in logs and status payload. - tests/swarm/test_state_schema.py covers legacy-dict roundtrip, field default, and swarm-mode assignments. --- decnet/agent/executor.py | 7 ++-- decnet/models.py | 3 ++ tests/swarm/test_state_schema.py | 60 ++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 tests/swarm/test_state_schema.py diff --git a/decnet/agent/executor.py b/decnet/agent/executor.py index 356f4f8..9e4ba5f 100644 --- a/decnet/agent/executor.py +++ b/decnet/agent/executor.py @@ -21,7 +21,10 @@ log = get_logger("agent.executor") async def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False) -> None: """Run the blocking deployer off-loop. The deployer itself calls save_state() internally once the compose file is materialised.""" - log.info("agent.deploy name=%s deckies=%d", config.name, len(config.deckies)) + log.info( + "agent.deploy mode=%s deckies=%d interface=%s", + config.mode, len(config.deckies), config.interface, + ) await asyncio.to_thread(_deployer.deploy, config, dry_run, no_cache, False) @@ -39,7 +42,7 @@ async def status() -> dict[str, Any]: config, _compose_path = state return { "deployed": True, - "name": getattr(config, "name", None), + "mode": config.mode, "compose_path": str(_compose_path), "deckies": [d.model_dump() for d in config.deckies], } diff --git a/decnet/models.py b/decnet/models.py index 1db29f2..ed5f955 100644 --- a/decnet/models.py +++ b/decnet/models.py @@ -99,6 +99,9 @@ class DeckyConfig(BaseModel): mutate_interval: int | None = None # automatic rotation interval in minutes last_mutated: float = 0.0 # timestamp of last mutation last_login_attempt: float = 0.0 # timestamp of most recent interaction + # SWARM: the SwarmHost.uuid that runs this decky. None in unihost mode + # so existing state files deserialize unchanged. + host_uuid: str | None = None @field_validator("services") @classmethod diff --git a/tests/swarm/test_state_schema.py b/tests/swarm/test_state_schema.py new file mode 100644 index 0000000..a8664d0 --- /dev/null +++ b/tests/swarm/test_state_schema.py @@ -0,0 +1,60 @@ +"""Backward-compatibility tests for the SWARM state-schema extension. + +DeckyConfig gained an optional ``host_uuid`` field in swarm mode. Existing +state files (unihost) must continue to deserialize without change. +""" +from __future__ import annotations + +from decnet.models import DeckyConfig, DecnetConfig + + +def _minimal_decky(name: str = "decky-01") -> dict: + return { + "name": name, + "ip": "192.168.1.10", + "services": ["ssh"], + "distro": "debian", + "base_image": "debian:bookworm-slim", + "hostname": "decky01", + } + + +def test_decky_config_host_uuid_defaults_to_none() -> None: + """A decky built from a pre-swarm state blob lands with host_uuid=None.""" + d = DeckyConfig(**_minimal_decky()) + assert d.host_uuid is None + + +def test_decky_config_accepts_host_uuid() -> None: + d = DeckyConfig(**_minimal_decky(), host_uuid="host-uuid-abc") + assert d.host_uuid == "host-uuid-abc" + + +def test_decnet_config_mode_swarm_with_host_assignments() -> None: + """Full swarm-mode config: every decky carries a host_uuid.""" + config = DecnetConfig( + mode="swarm", + interface="eth0", + subnet="192.168.1.0/24", + gateway="192.168.1.1", + deckies=[ + DeckyConfig(**_minimal_decky("decky-01"), host_uuid="host-A"), + DeckyConfig(**_minimal_decky("decky-02"), host_uuid="host-B"), + ], + ) + assert config.mode == "swarm" + assert {d.host_uuid for d in config.deckies} == {"host-A", "host-B"} + + +def test_legacy_unihost_state_still_parses() -> None: + """A dict matching the pre-swarm schema deserializes unchanged.""" + legacy_blob = { + "mode": "unihost", + "interface": "eth0", + "subnet": "192.168.1.0/24", + "gateway": "192.168.1.1", + "deckies": [_minimal_decky()], + } + config = DecnetConfig.model_validate(legacy_blob) + assert config.mode == "unihost" + assert config.deckies[0].host_uuid is None