refactor: separate engine, collector, mutator, and fleet into independent subpackages
- decnet/engine/ — container lifecycle (deploy, teardown, status); _kill_api removed - decnet/collector/ — Docker log streaming (moved from web/collector.py) - decnet/mutator/ — mutation engine (no longer imports from cli or duplicates deployer code) - decnet/fleet.py — shared decky-building logic extracted from cli.py Cross-contamination eliminated: - web router no longer imports from decnet.cli - mutator no longer imports from decnet.cli - cli no longer imports from decnet.web - _kill_api() moved to cli (process management, not engine concern) - _compose_with_retry duplicate removed from mutator
This commit is contained in:
@@ -266,7 +266,7 @@ def test_ini_subsection_direct_match_unaffected():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_deckies_archetype_sets_services():
|
||||
from decnet.cli import _build_deckies
|
||||
from decnet.fleet import build_deckies as _build_deckies
|
||||
from decnet.archetypes import get_archetype
|
||||
arch = get_archetype("mail-server")
|
||||
result = _build_deckies(
|
||||
@@ -283,7 +283,7 @@ def test_build_deckies_archetype_sets_services():
|
||||
|
||||
|
||||
def test_build_deckies_archetype_preferred_distros():
|
||||
from decnet.cli import _build_deckies
|
||||
from decnet.fleet import build_deckies as _build_deckies
|
||||
from decnet.archetypes import get_archetype
|
||||
arch = get_archetype("iot-device") # preferred_distros=["alpine"]
|
||||
result = _build_deckies(
|
||||
@@ -298,7 +298,7 @@ def test_build_deckies_archetype_preferred_distros():
|
||||
|
||||
|
||||
def test_build_deckies_explicit_services_override_archetype():
|
||||
from decnet.cli import _build_deckies
|
||||
from decnet.fleet import build_deckies as _build_deckies
|
||||
from decnet.archetypes import get_archetype
|
||||
arch = get_archetype("linux-server")
|
||||
result = _build_deckies(
|
||||
|
||||
@@ -8,7 +8,13 @@ MODULES = [
|
||||
"decnet.cli",
|
||||
"decnet.config",
|
||||
"decnet.composer",
|
||||
"decnet.deployer",
|
||||
"decnet.engine",
|
||||
"decnet.engine.deployer",
|
||||
"decnet.collector",
|
||||
"decnet.collector.worker",
|
||||
"decnet.mutator",
|
||||
"decnet.mutator.engine",
|
||||
"decnet.fleet",
|
||||
"decnet.network",
|
||||
"decnet.archetypes",
|
||||
"decnet.distros",
|
||||
|
||||
@@ -3,7 +3,7 @@ Tests for the CLI service pool — verifies that --randomize-services draws
|
||||
from all registered services, not just the original hardcoded 5.
|
||||
"""
|
||||
|
||||
from decnet.cli import _all_service_names, _build_deckies
|
||||
from decnet.fleet import all_service_names as _all_service_names, build_deckies as _build_deckies
|
||||
from decnet.services.registry import all_services
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from decnet.web.collector import parse_rfc5424, is_service_container, is_service_event
|
||||
from decnet.collector import parse_rfc5424, is_service_container, is_service_event
|
||||
|
||||
_KNOWN_NAMES = {"omega-decky-http", "omega-decky-smtp", "relay-decky-ftp"}
|
||||
|
||||
@@ -91,42 +91,42 @@ class TestParseRfc5424:
|
||||
|
||||
class TestIsServiceContainer:
|
||||
def test_known_container_returns_true(self):
|
||||
with patch("decnet.web.collector._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
assert is_service_container(_make_container("omega-decky-http")) is True
|
||||
assert is_service_container(_make_container("omega-decky-smtp")) is True
|
||||
assert is_service_container(_make_container("relay-decky-ftp")) is True
|
||||
|
||||
def test_base_container_returns_false(self):
|
||||
with patch("decnet.web.collector._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
assert is_service_container(_make_container("omega-decky")) is False
|
||||
|
||||
def test_unrelated_container_returns_false(self):
|
||||
with patch("decnet.web.collector._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
assert is_service_container(_make_container("nginx")) is False
|
||||
|
||||
def test_strips_leading_slash(self):
|
||||
with patch("decnet.web.collector._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
assert is_service_container(_make_container("/omega-decky-http")) is True
|
||||
assert is_service_container(_make_container("/omega-decky")) is False
|
||||
|
||||
def test_no_state_returns_false(self):
|
||||
with patch("decnet.web.collector._load_service_container_names", return_value=set()):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=set()):
|
||||
assert is_service_container(_make_container("omega-decky-http")) is False
|
||||
|
||||
|
||||
class TestIsServiceEvent:
|
||||
def test_known_service_event_returns_true(self):
|
||||
with patch("decnet.web.collector._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
assert is_service_event({"name": "omega-decky-smtp"}) is True
|
||||
|
||||
def test_base_event_returns_false(self):
|
||||
with patch("decnet.web.collector._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
assert is_service_event({"name": "omega-decky"}) is False
|
||||
|
||||
def test_unrelated_event_returns_false(self):
|
||||
with patch("decnet.web.collector._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
assert is_service_event({"name": "nginx"}) is False
|
||||
|
||||
def test_no_state_returns_false(self):
|
||||
with patch("decnet.web.collector._load_service_container_names", return_value=set()):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=set()):
|
||||
assert is_service_event({"name": "omega-decky-smtp"}) is False
|
||||
|
||||
@@ -10,7 +10,8 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from decnet.config import DeckyConfig, DecnetConfig
|
||||
from decnet.mutator import _compose_with_retry, mutate_all, mutate_decky
|
||||
from decnet.engine import _compose_with_retry
|
||||
from decnet.mutator import mutate_all, mutate_decky
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -48,37 +49,37 @@ def _make_config(deckies=None, mutate_interval=30):
|
||||
class TestComposeWithRetry:
|
||||
def test_succeeds_on_first_attempt(self):
|
||||
result = MagicMock(returncode=0, stdout="done\n")
|
||||
with patch("decnet.mutator.subprocess.run", return_value=result) as mock_run:
|
||||
with patch("decnet.engine.deployer.subprocess.run", return_value=result) as mock_run:
|
||||
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"))
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_retries_on_failure_then_succeeds(self):
|
||||
fail = MagicMock(returncode=1, stdout="", stderr="transient error")
|
||||
ok = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("decnet.mutator.subprocess.run", side_effect=[fail, ok]) as mock_run, \
|
||||
patch("decnet.mutator.time.sleep"):
|
||||
with patch("decnet.engine.deployer.subprocess.run", side_effect=[fail, ok]) as mock_run, \
|
||||
patch("decnet.engine.deployer.time.sleep"):
|
||||
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"), retries=3)
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_raises_after_all_retries_exhausted(self):
|
||||
fail = MagicMock(returncode=1, stdout="", stderr="hard error")
|
||||
with patch("decnet.mutator.subprocess.run", return_value=fail), \
|
||||
patch("decnet.mutator.time.sleep"):
|
||||
with patch("decnet.engine.deployer.subprocess.run", return_value=fail), \
|
||||
patch("decnet.engine.deployer.time.sleep"):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"), retries=3)
|
||||
|
||||
def test_exponential_backoff(self):
|
||||
fail = MagicMock(returncode=1, stdout="", stderr="")
|
||||
sleep_calls = []
|
||||
with patch("decnet.mutator.subprocess.run", return_value=fail), \
|
||||
patch("decnet.mutator.time.sleep", side_effect=lambda d: sleep_calls.append(d)):
|
||||
with patch("decnet.engine.deployer.subprocess.run", return_value=fail), \
|
||||
patch("decnet.engine.deployer.time.sleep", side_effect=lambda d: sleep_calls.append(d)):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_compose_with_retry("up", compose_file=Path("c.yml"), retries=3, delay=1.0)
|
||||
assert sleep_calls == [1.0, 2.0]
|
||||
|
||||
def test_correct_command_structure(self):
|
||||
ok = MagicMock(returncode=0, stdout="")
|
||||
with patch("decnet.mutator.subprocess.run", return_value=ok) as mock_run:
|
||||
with patch("decnet.engine.deployer.subprocess.run", return_value=ok) as mock_run:
|
||||
_compose_with_retry("up", "-d", "--remove-orphans",
|
||||
compose_file=Path("/tmp/compose.yml"))
|
||||
cmd = mock_run.call_args[0][0]
|
||||
@@ -96,14 +97,14 @@ class TestMutateDecky:
|
||||
"""Return a context manager that mocks all I/O in mutate_decky."""
|
||||
cfg = config or _make_config()
|
||||
return (
|
||||
patch("decnet.mutator.load_state", return_value=(cfg, compose_path)),
|
||||
patch("decnet.mutator.save_state"),
|
||||
patch("decnet.mutator.write_compose"),
|
||||
patch("decnet.mutator._compose_with_retry"),
|
||||
patch("decnet.mutator.engine.load_state", return_value=(cfg, compose_path)),
|
||||
patch("decnet.mutator.engine.save_state"),
|
||||
patch("decnet.mutator.engine.write_compose"),
|
||||
patch("decnet.mutator.engine._compose_with_retry"),
|
||||
)
|
||||
|
||||
def test_returns_false_when_no_state(self):
|
||||
with patch("decnet.mutator.load_state", return_value=None):
|
||||
with patch("decnet.mutator.engine.load_state", return_value=None):
|
||||
assert mutate_decky("decky-01") is False
|
||||
|
||||
def test_returns_false_when_decky_not_found(self):
|
||||
@@ -118,20 +119,20 @@ class TestMutateDecky:
|
||||
|
||||
def test_saves_state_after_mutation(self):
|
||||
p = self._patch()
|
||||
with p[0], patch("decnet.mutator.save_state") as mock_save, p[2], p[3]:
|
||||
with p[0], patch("decnet.mutator.engine.save_state") as mock_save, p[2], p[3]:
|
||||
mutate_decky("decky-01")
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_regenerates_compose_after_mutation(self):
|
||||
p = self._patch()
|
||||
with p[0], p[1], patch("decnet.mutator.write_compose") as mock_compose, p[3]:
|
||||
with p[0], p[1], patch("decnet.mutator.engine.write_compose") as mock_compose, p[3]:
|
||||
mutate_decky("decky-01")
|
||||
mock_compose.assert_called_once()
|
||||
|
||||
def test_returns_false_on_compose_failure(self):
|
||||
p = self._patch()
|
||||
err = subprocess.CalledProcessError(1, "docker", "", "compose failed")
|
||||
with p[0], p[1], p[2], patch("decnet.mutator._compose_with_retry", side_effect=err):
|
||||
with p[0], p[1], p[2], patch("decnet.mutator.engine._compose_with_retry", side_effect=err):
|
||||
assert mutate_decky("decky-01") is False
|
||||
|
||||
def test_mutation_changes_services(self):
|
||||
@@ -166,15 +167,15 @@ class TestMutateDecky:
|
||||
|
||||
class TestMutateAll:
|
||||
def test_no_state_returns_early(self):
|
||||
with patch("decnet.mutator.load_state", return_value=None), \
|
||||
patch("decnet.mutator.mutate_decky") as mock_mutate:
|
||||
with patch("decnet.mutator.engine.load_state", return_value=None), \
|
||||
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
mutate_all()
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
def test_force_mutates_all_deckies(self):
|
||||
cfg = _make_config(deckies=[_make_decky("d1"), _make_decky("d2")])
|
||||
with patch("decnet.mutator.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.mutate_decky", return_value=True) as mock_mutate:
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky", return_value=True) as mock_mutate:
|
||||
mutate_all(force=True)
|
||||
assert mock_mutate.call_count == 2
|
||||
|
||||
@@ -182,8 +183,8 @@ class TestMutateAll:
|
||||
# last_mutated = now, interval = 30 min → not due
|
||||
now = time.time()
|
||||
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=now)])
|
||||
with patch("decnet.mutator.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.mutate_decky") as mock_mutate:
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
mutate_all(force=False)
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
@@ -191,8 +192,8 @@ class TestMutateAll:
|
||||
# last_mutated = 2 hours ago, interval = 30 min → due
|
||||
old_ts = time.time() - 7200
|
||||
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=old_ts)])
|
||||
with patch("decnet.mutator.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.mutate_decky", return_value=True) as mock_mutate:
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky", return_value=True) as mock_mutate:
|
||||
mutate_all(force=False)
|
||||
mock_mutate.assert_called_once_with("decky-01")
|
||||
|
||||
@@ -201,7 +202,7 @@ class TestMutateAll:
|
||||
deckies=[_make_decky(mutate_interval=None)],
|
||||
mutate_interval=None,
|
||||
)
|
||||
with patch("decnet.mutator.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.mutate_decky") as mock_mutate:
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
mutate_all(force=False)
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
@@ -434,7 +434,7 @@ def test_compose_embedded_sysctls_full_set():
|
||||
|
||||
def test_build_deckies_windows_archetype_sets_nmap_os():
|
||||
from decnet.archetypes import get_archetype
|
||||
from decnet.cli import _build_deckies
|
||||
from decnet.fleet import build_deckies as _build_deckies
|
||||
|
||||
arch = get_archetype("windows-workstation")
|
||||
deckies = _build_deckies(
|
||||
@@ -448,7 +448,7 @@ def test_build_deckies_windows_archetype_sets_nmap_os():
|
||||
|
||||
|
||||
def test_build_deckies_no_archetype_defaults_linux():
|
||||
from decnet.cli import _build_deckies
|
||||
from decnet.fleet import build_deckies as _build_deckies
|
||||
|
||||
deckies = _build_deckies(
|
||||
n=1,
|
||||
@@ -462,7 +462,7 @@ def test_build_deckies_no_archetype_defaults_linux():
|
||||
|
||||
def test_build_deckies_embedded_archetype_sets_nmap_os():
|
||||
from decnet.archetypes import get_archetype
|
||||
from decnet.cli import _build_deckies
|
||||
from decnet.fleet import build_deckies as _build_deckies
|
||||
|
||||
arch = get_archetype("iot-device")
|
||||
deckies = _build_deckies(
|
||||
|
||||
Reference in New Issue
Block a user