Add machine archetypes and amount= expansion
Introduces archetype profiles (windows-workstation, linux-server, domain-controller, printer, iot-device, etc.) so users get a realistic service+distro combination without knowing which services to pick. Adds amount= to INI config (and CLI --archetype) so a single section can spawn N identical deckies without copy-paste. Per-service subsections (e.g. [group.ssh]) propagate to all expanded instances automatically. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
312
tests/test_archetypes.py
Normal file
312
tests/test_archetypes.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Tests for machine archetypes and the amount= expansion feature.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
import tempfile
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from decnet.archetypes import (
|
||||
ARCHETYPES,
|
||||
all_archetypes,
|
||||
get_archetype,
|
||||
random_archetype,
|
||||
)
|
||||
from decnet.ini_loader import load_ini, DeckySpec
|
||||
from decnet.distros import DISTROS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Archetype registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_all_archetypes_returns_all():
|
||||
result = all_archetypes()
|
||||
assert isinstance(result, dict)
|
||||
assert len(result) == len(ARCHETYPES)
|
||||
|
||||
|
||||
def test_get_archetype_known():
|
||||
arch = get_archetype("linux-server")
|
||||
assert arch.slug == "linux-server"
|
||||
assert "ssh" in arch.services
|
||||
|
||||
|
||||
def test_get_archetype_unknown_raises():
|
||||
with pytest.raises(ValueError, match="Unknown archetype"):
|
||||
get_archetype("does-not-exist")
|
||||
|
||||
|
||||
def test_random_archetype_returns_valid():
|
||||
arch = random_archetype()
|
||||
assert arch.slug in ARCHETYPES
|
||||
|
||||
|
||||
def test_every_archetype_has_services():
|
||||
for slug, arch in ARCHETYPES.items():
|
||||
assert arch.services, f"Archetype '{slug}' has no services"
|
||||
|
||||
|
||||
def test_every_archetype_has_preferred_distros():
|
||||
for slug, arch in ARCHETYPES.items():
|
||||
assert arch.preferred_distros, f"Archetype '{slug}' has no preferred_distros"
|
||||
|
||||
|
||||
def test_every_archetype_preferred_distro_is_valid():
|
||||
valid_slugs = set(DISTROS.keys())
|
||||
for slug, arch in ARCHETYPES.items():
|
||||
for d in arch.preferred_distros:
|
||||
assert d in valid_slugs, (
|
||||
f"Archetype '{slug}' references unknown distro '{d}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# INI loader — archetype= parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _write_ini(content: str) -> str:
|
||||
"""Write INI content to a temp file and return the path."""
|
||||
content = textwrap.dedent(content)
|
||||
fd, path = tempfile.mkstemp(suffix=".ini")
|
||||
os.write(fd, content.encode())
|
||||
os.close(fd)
|
||||
return path
|
||||
|
||||
|
||||
def test_ini_archetype_parsed():
|
||||
path = _write_ini("""
|
||||
[general]
|
||||
net=10.0.0.0/24
|
||||
gw=10.0.0.1
|
||||
|
||||
[my-server]
|
||||
archetype=linux-server
|
||||
""")
|
||||
cfg = load_ini(path)
|
||||
os.unlink(path)
|
||||
assert len(cfg.deckies) == 1
|
||||
assert cfg.deckies[0].archetype == "linux-server"
|
||||
assert cfg.deckies[0].services is None # not overridden
|
||||
|
||||
|
||||
def test_ini_archetype_with_explicit_services_override():
|
||||
"""explicit services= must survive alongside archetype="""
|
||||
path = _write_ini("""
|
||||
[general]
|
||||
net=10.0.0.0/24
|
||||
gw=10.0.0.1
|
||||
|
||||
[my-server]
|
||||
archetype=linux-server
|
||||
services=ftp,smb
|
||||
""")
|
||||
cfg = load_ini(path)
|
||||
os.unlink(path)
|
||||
assert cfg.deckies[0].archetype == "linux-server"
|
||||
assert cfg.deckies[0].services == ["ftp", "smb"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# INI loader — amount= expansion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ini_amount_one_keeps_section_name():
|
||||
path = _write_ini("""
|
||||
[general]
|
||||
net=10.0.0.0/24
|
||||
gw=10.0.0.1
|
||||
|
||||
[my-printer]
|
||||
archetype=printer
|
||||
amount=1
|
||||
""")
|
||||
cfg = load_ini(path)
|
||||
os.unlink(path)
|
||||
assert len(cfg.deckies) == 1
|
||||
assert cfg.deckies[0].name == "my-printer"
|
||||
|
||||
|
||||
def test_ini_amount_expands_deckies():
|
||||
path = _write_ini("""
|
||||
[general]
|
||||
net=10.0.0.0/24
|
||||
gw=10.0.0.1
|
||||
|
||||
[corp-ws]
|
||||
archetype=windows-workstation
|
||||
amount=5
|
||||
""")
|
||||
cfg = load_ini(path)
|
||||
os.unlink(path)
|
||||
assert len(cfg.deckies) == 5
|
||||
for i, d in enumerate(cfg.deckies, start=1):
|
||||
assert d.name == f"corp-ws-{i:02d}"
|
||||
assert d.archetype == "windows-workstation"
|
||||
assert d.ip is None # auto-allocated
|
||||
|
||||
|
||||
def test_ini_amount_with_ip_raises():
|
||||
path = _write_ini("""
|
||||
[general]
|
||||
net=10.0.0.0/24
|
||||
gw=10.0.0.1
|
||||
|
||||
[bad-group]
|
||||
services=ssh
|
||||
ip=10.0.0.50
|
||||
amount=3
|
||||
""")
|
||||
with pytest.raises(ValueError, match="Cannot combine ip="):
|
||||
load_ini(path)
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_ini_amount_invalid_value_raises():
|
||||
path = _write_ini("""
|
||||
[general]
|
||||
net=10.0.0.0/24
|
||||
gw=10.0.0.1
|
||||
|
||||
[bad]
|
||||
services=ssh
|
||||
amount=potato
|
||||
""")
|
||||
with pytest.raises(ValueError, match="must be a positive integer"):
|
||||
load_ini(path)
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_ini_amount_zero_raises():
|
||||
path = _write_ini("""
|
||||
[general]
|
||||
net=10.0.0.0/24
|
||||
gw=10.0.0.1
|
||||
|
||||
[bad]
|
||||
services=ssh
|
||||
amount=0
|
||||
""")
|
||||
with pytest.raises(ValueError, match="must be a positive integer"):
|
||||
load_ini(path)
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_ini_amount_multiple_groups():
|
||||
"""Two groups with different amounts expand independently."""
|
||||
path = _write_ini("""
|
||||
[general]
|
||||
net=10.0.0.0/24
|
||||
gw=10.0.0.1
|
||||
|
||||
[workers]
|
||||
archetype=linux-server
|
||||
amount=3
|
||||
|
||||
[printers]
|
||||
archetype=printer
|
||||
amount=2
|
||||
""")
|
||||
cfg = load_ini(path)
|
||||
os.unlink(path)
|
||||
assert len(cfg.deckies) == 5
|
||||
names = [d.name for d in cfg.deckies]
|
||||
assert names == ["workers-01", "workers-02", "workers-03", "printers-01", "printers-02"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# INI loader — per-service subsections propagate to expanded deckies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ini_subsection_propagates_to_expanded_deckies():
|
||||
"""[group.ssh] must apply to group-01, group-02, ..."""
|
||||
path = _write_ini("""
|
||||
[general]
|
||||
net=10.0.0.0/24
|
||||
gw=10.0.0.1
|
||||
|
||||
[linux-hosts]
|
||||
archetype=linux-server
|
||||
amount=3
|
||||
|
||||
[linux-hosts.ssh]
|
||||
kernel_version=5.15.0-76-generic
|
||||
""")
|
||||
cfg = load_ini(path)
|
||||
os.unlink(path)
|
||||
assert len(cfg.deckies) == 3
|
||||
for d in cfg.deckies:
|
||||
assert "ssh" in d.service_config
|
||||
assert d.service_config["ssh"]["kernel_version"] == "5.15.0-76-generic"
|
||||
|
||||
|
||||
def test_ini_subsection_direct_match_unaffected():
|
||||
"""A direct [decky.svc] subsection must still work when amount=1."""
|
||||
path = _write_ini("""
|
||||
[general]
|
||||
net=10.0.0.0/24
|
||||
gw=10.0.0.1
|
||||
|
||||
[web-01]
|
||||
services=http
|
||||
|
||||
[web-01.http]
|
||||
server_header=Apache/2.4.51
|
||||
""")
|
||||
cfg = load_ini(path)
|
||||
os.unlink(path)
|
||||
assert cfg.deckies[0].service_config["http"]["server_header"] == "Apache/2.4.51"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_deckies — archetype applied via CLI path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_deckies_archetype_sets_services():
|
||||
from decnet.cli import _build_deckies
|
||||
from decnet.archetypes import get_archetype
|
||||
arch = get_archetype("mail-server")
|
||||
result = _build_deckies(
|
||||
n=2,
|
||||
ips=["10.0.0.10", "10.0.0.11"],
|
||||
services_explicit=None,
|
||||
randomize_services=False,
|
||||
archetype=arch,
|
||||
)
|
||||
assert len(result) == 2
|
||||
for d in result:
|
||||
assert set(d.services) == set(arch.services)
|
||||
assert d.archetype == "mail-server"
|
||||
|
||||
|
||||
def test_build_deckies_archetype_preferred_distros():
|
||||
from decnet.cli import _build_deckies
|
||||
from decnet.archetypes import get_archetype
|
||||
arch = get_archetype("iot-device") # preferred_distros=["alpine"]
|
||||
result = _build_deckies(
|
||||
n=3,
|
||||
ips=["10.0.0.10", "10.0.0.11", "10.0.0.12"],
|
||||
services_explicit=None,
|
||||
randomize_services=False,
|
||||
archetype=arch,
|
||||
)
|
||||
for d in result:
|
||||
assert d.distro == "alpine"
|
||||
|
||||
|
||||
def test_build_deckies_explicit_services_override_archetype():
|
||||
from decnet.cli import _build_deckies
|
||||
from decnet.archetypes import get_archetype
|
||||
arch = get_archetype("linux-server")
|
||||
result = _build_deckies(
|
||||
n=1,
|
||||
ips=["10.0.0.10"],
|
||||
services_explicit=["ftp"],
|
||||
randomize_services=False,
|
||||
archetype=arch,
|
||||
)
|
||||
assert result[0].services == ["ftp"]
|
||||
assert result[0].archetype == "linux-server"
|
||||
Reference in New Issue
Block a user