diff --git a/decnet/cli.py b/decnet/cli.py index 97f75c5..3336dba 100644 --- a/decnet/cli.py +++ b/decnet/cli.py @@ -32,7 +32,9 @@ app = typer.Typer( ) console = Console() -ALL_SERVICE_NAMES = ["ssh", "smb", "rdp", "http", "ftp"] +def _all_service_names() -> list[str]: + """Return all registered service names from the live plugin registry.""" + return sorted(all_services().keys()) def _resolve_distros( @@ -71,11 +73,12 @@ def _build_deckies( if services_explicit: svc_list = services_explicit elif randomize_services: - # Pick 1-3 random services, try to avoid exact duplicates + # Pick 1-3 random services from the full registry, avoid exact duplicates + svc_pool = _all_service_names() attempts = 0 while True: - count = random.randint(1, min(3, len(ALL_SERVICE_NAMES))) - chosen = frozenset(random.sample(ALL_SERVICE_NAMES, count)) + count = random.randint(1, min(3, len(svc_pool))) + chosen = frozenset(random.sample(svc_pool, count)) attempts += 1 if chosen not in used_combos or attempts > 20: break @@ -92,6 +95,7 @@ def _build_deckies( services=svc_list, distro=distro.slug, base_image=distro.image, + build_base=distro.build_base, hostname=hostname, ) ) @@ -136,18 +140,19 @@ def _build_deckies_from_ini( ) if spec.services: - known = set(ALL_SERVICE_NAMES) + known = set(_all_service_names()) unknown = [s for s in spec.services if s not in known] if unknown: console.print( f"[red]Unknown service(s) in [{spec.name}]: {unknown}. " - f"Available: {ALL_SERVICE_NAMES}[/]" + f"Available: {_all_service_names()}[/]" ) raise typer.Exit(1) svc_list = spec.services elif randomize: - count = random.randint(1, min(3, len(ALL_SERVICE_NAMES))) - svc_list = random.sample(ALL_SERVICE_NAMES, count) + svc_pool = _all_service_names() + count = random.randint(1, min(3, len(svc_pool))) + svc_list = random.sample(svc_pool, count) else: console.print( f"[red]Decky '[{spec.name}]' has no services= in config. " @@ -161,6 +166,7 @@ def _build_deckies_from_ini( services=svc_list, distro=distro.slug, base_image=distro.image, + build_base=distro.build_base, hostname=hostname, )) return deckies @@ -225,10 +231,10 @@ def deploy( services_list = [s.strip() for s in services.split(",")] if services else None if services_list: - known = set(ALL_SERVICE_NAMES) + known = set(_all_service_names()) unknown = [s for s in services_list if s not in known] if unknown: - console.print(f"[red]Unknown service(s): {unknown}. Available: {ALL_SERVICE_NAMES}[/]") + console.print(f"[red]Unknown service(s): {unknown}. Available: {_all_service_names()}[/]") raise typer.Exit(1) if not services_list and not randomize_services: diff --git a/decnet/composer.py b/decnet/composer.py index 641c7e0..6c092f1 100644 --- a/decnet/composer.py +++ b/decnet/composer.py @@ -48,6 +48,11 @@ def generate_compose(config: DecnetConfig) -> dict: svc = get_service(svc_name) fragment = svc.compose_fragment(decky.name, log_target=config.log_target) + # Inject the per-decky base image into build services so containers + # vary by distro and don't all fingerprint as debian:bookworm-slim. + if "build" in fragment: + fragment["build"].setdefault("args", {})["BASE_IMAGE"] = decky.build_base + fragment.setdefault("environment", {}) fragment["environment"]["HOSTNAME"] = decky.hostname diff --git a/decnet/config.py b/decnet/config.py index bb6020a..5adfbf8 100644 --- a/decnet/config.py +++ b/decnet/config.py @@ -23,7 +23,8 @@ class DeckyConfig(BaseModel): ip: str services: list[str] distro: str # slug from distros.DISTROS, e.g. "debian", "ubuntu22" - base_image: str # resolved Docker image tag + base_image: str # Docker image for the base/IP-holder container + build_base: str = "debian:bookworm-slim" # apt-compatible image for service Dockerfiles hostname: str @field_validator("services") diff --git a/decnet/distros.py b/decnet/distros.py index e0e8265..40fc8ab 100644 --- a/decnet/distros.py +++ b/decnet/distros.py @@ -12,9 +12,10 @@ from dataclasses import dataclass @dataclass(frozen=True) class DistroProfile: slug: str # CLI-facing identifier, e.g. "debian", "rocky9" - image: str # Docker image tag + image: str # Docker image tag (used for the base/IP-holder container) display_name: str # Human-readable label shown in tables hostname_style: str # "generic" | "rhel" | "minimal" | "rolling" + build_base: str # apt-compatible image for service Dockerfiles (FROM ${BASE_IMAGE}) DISTROS: dict[str, DistroProfile] = { @@ -23,54 +24,63 @@ DISTROS: dict[str, DistroProfile] = { image="debian:bookworm-slim", display_name="Debian 12 (Bookworm)", hostname_style="generic", + build_base="debian:bookworm-slim", ), "ubuntu22": DistroProfile( slug="ubuntu22", image="ubuntu:22.04", display_name="Ubuntu 22.04 LTS (Jammy)", hostname_style="generic", + build_base="ubuntu:22.04", ), "ubuntu20": DistroProfile( slug="ubuntu20", image="ubuntu:20.04", display_name="Ubuntu 20.04 LTS (Focal)", hostname_style="generic", + build_base="ubuntu:20.04", ), "rocky9": DistroProfile( slug="rocky9", image="rockylinux:9-minimal", display_name="Rocky Linux 9", hostname_style="rhel", + build_base="debian:bookworm-slim", # Dockerfiles use apt-get; fall back to debian ), "centos7": DistroProfile( slug="centos7", image="centos:7", display_name="CentOS 7", hostname_style="rhel", + build_base="debian:bookworm-slim", # Dockerfiles use apt-get; fall back to debian ), "alpine": DistroProfile( slug="alpine", image="alpine:3.19", display_name="Alpine Linux 3.19", hostname_style="minimal", + build_base="debian:bookworm-slim", # Dockerfiles use apt-get; fall back to debian ), "fedora": DistroProfile( slug="fedora", image="fedora:39", display_name="Fedora 39", hostname_style="rhel", + build_base="debian:bookworm-slim", # Dockerfiles use apt-get; fall back to debian ), "kali": DistroProfile( slug="kali", image="kalilinux/kali-rolling", display_name="Kali Linux (Rolling)", hostname_style="rolling", + build_base="kalilinux/kali-rolling", # Debian-based, apt-get compatible ), "arch": DistroProfile( slug="arch", image="archlinux:latest", display_name="Arch Linux", hostname_style="rolling", + build_base="debian:bookworm-slim", # Dockerfiles use apt-get; fall back to debian ), } diff --git a/templates/cowrie/Dockerfile b/templates/cowrie/Dockerfile index a50ff71..5de21d7 100644 --- a/templates/cowrie/Dockerfile +++ b/templates/cowrie/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-pip python3-venv \ diff --git a/templates/docker_api/Dockerfile b/templates/docker_api/Dockerfile index 0333d86..2f46fe4 100644 --- a/templates/docker_api/Dockerfile +++ b/templates/docker_api/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-pip \ diff --git a/templates/ftp/Dockerfile b/templates/ftp/Dockerfile index 9db611f..737ed17 100644 --- a/templates/ftp/Dockerfile +++ b/templates/ftp/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-pip \ diff --git a/templates/http/Dockerfile b/templates/http/Dockerfile index 9846d0f..5c0f6c7 100644 --- a/templates/http/Dockerfile +++ b/templates/http/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-pip \ diff --git a/templates/imap/Dockerfile b/templates/imap/Dockerfile index a59a695..600870e 100644 --- a/templates/imap/Dockerfile +++ b/templates/imap/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/k8s/Dockerfile b/templates/k8s/Dockerfile index ffa79c3..de638e0 100644 --- a/templates/k8s/Dockerfile +++ b/templates/k8s/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-pip \ diff --git a/templates/ldap/Dockerfile b/templates/ldap/Dockerfile index 38fad21..1d1552e 100644 --- a/templates/ldap/Dockerfile +++ b/templates/ldap/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/llmnr/Dockerfile b/templates/llmnr/Dockerfile index a8be0a6..785e135 100644 --- a/templates/llmnr/Dockerfile +++ b/templates/llmnr/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/mongodb/Dockerfile b/templates/mongodb/Dockerfile index d05560e..f60b049 100644 --- a/templates/mongodb/Dockerfile +++ b/templates/mongodb/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/mqtt/Dockerfile b/templates/mqtt/Dockerfile index 2768f73..3c902bb 100644 --- a/templates/mqtt/Dockerfile +++ b/templates/mqtt/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/mssql/Dockerfile b/templates/mssql/Dockerfile index 884fe4c..c4fc81a 100644 --- a/templates/mssql/Dockerfile +++ b/templates/mssql/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/mysql/Dockerfile b/templates/mysql/Dockerfile index a55123b..c271b71 100644 --- a/templates/mysql/Dockerfile +++ b/templates/mysql/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/pop3/Dockerfile b/templates/pop3/Dockerfile index 850707e..4ed4860 100644 --- a/templates/pop3/Dockerfile +++ b/templates/pop3/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/postgres/Dockerfile b/templates/postgres/Dockerfile index 7c62c04..9b64494 100644 --- a/templates/postgres/Dockerfile +++ b/templates/postgres/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/rdp/Dockerfile b/templates/rdp/Dockerfile index 75b96cd..6ad04d1 100644 --- a/templates/rdp/Dockerfile +++ b/templates/rdp/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-pip \ diff --git a/templates/redis/Dockerfile b/templates/redis/Dockerfile index bf7cf4d..e5a0048 100644 --- a/templates/redis/Dockerfile +++ b/templates/redis/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/sip/Dockerfile b/templates/sip/Dockerfile index f2bf74b..225380e 100644 --- a/templates/sip/Dockerfile +++ b/templates/sip/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/smb/Dockerfile b/templates/smb/Dockerfile index f6ce1af..4910053 100644 --- a/templates/smb/Dockerfile +++ b/templates/smb/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-pip \ diff --git a/templates/snmp/Dockerfile b/templates/snmp/Dockerfile index 0ff4fdc..73d3fa6 100644 --- a/templates/snmp/Dockerfile +++ b/templates/snmp/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/tftp/Dockerfile b/templates/tftp/Dockerfile index 34520fc..f1330ea 100644 --- a/templates/tftp/Dockerfile +++ b/templates/tftp/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/templates/vnc/Dockerfile b/templates/vnc/Dockerfile index e7465e0..905e74a 100644 --- a/templates/vnc/Dockerfile +++ b/templates/vnc/Dockerfile @@ -1,4 +1,5 @@ -FROM debian:bookworm-slim +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ diff --git a/tests/test_cli_service_pool.py b/tests/test_cli_service_pool.py new file mode 100644 index 0000000..5b648f4 --- /dev/null +++ b/tests/test_cli_service_pool.py @@ -0,0 +1,81 @@ +""" +Tests for the CLI service pool — verifies that --randomize-services draws +from all registered services, not just the original hardcoded 5. +""" + +import pytest +from decnet.cli import _all_service_names, _build_deckies +from decnet.services.registry import all_services + + +ORIGINAL_5 = {"ssh", "smb", "rdp", "http", "ftp"} + + +def test_all_service_names_covers_full_registry(): + """_all_service_names() must return every service in the registry.""" + pool = set(_all_service_names()) + registry = set(all_services().keys()) + assert pool == registry + + +def test_all_service_names_is_sorted(): + names = _all_service_names() + assert names == sorted(names) + + +def test_all_service_names_includes_at_least_25(): + assert len(_all_service_names()) >= 25 + + +def test_all_service_names_includes_all_original_5(): + pool = set(_all_service_names()) + assert ORIGINAL_5.issubset(pool) + + +def test_randomize_services_pool_exceeds_original_5(): + """ + After enough random draws, at least one service outside the original 5 must appear. + With 25 services and picking 1-3 at a time, 200 draws makes this ~100% certain. + """ + all_drawn: set[str] = set() + for _ in range(200): + deckies = _build_deckies( + n=1, + ips=["10.0.0.10"], + services_explicit=None, + randomize_services=True, + ) + all_drawn.update(deckies[0].services) + + beyond_original = all_drawn - ORIGINAL_5 + assert beyond_original, ( + f"After 200 draws only saw the original 5 services. " + f"All drawn: {sorted(all_drawn)}" + ) + + +def test_build_deckies_randomize_services_valid(): + """All randomly chosen services must exist in the registry.""" + registry = set(all_services().keys()) + for _ in range(50): + deckies = _build_deckies( + n=3, + ips=["10.0.0.10", "10.0.0.11", "10.0.0.12"], + services_explicit=None, + randomize_services=True, + ) + for decky in deckies: + unknown = set(decky.services) - registry + assert not unknown, f"Decky {decky.name} got unknown services: {unknown}" + + +def test_build_deckies_explicit_services_unchanged(): + """Explicit service list must pass through untouched.""" + deckies = _build_deckies( + n=2, + ips=["10.0.0.10", "10.0.0.11"], + services_explicit=["ssh", "ftp"], + randomize_services=False, + ) + for decky in deckies: + assert decky.services == ["ssh", "ftp"] diff --git a/tests/test_composer.py b/tests/test_composer.py new file mode 100644 index 0000000..298679c --- /dev/null +++ b/tests/test_composer.py @@ -0,0 +1,163 @@ +""" +Tests for the composer — verifies BASE_IMAGE injection and distro heterogeneity. +""" + +import pytest +from decnet.config import DeckyConfig, DecnetConfig +from decnet.composer import generate_compose +from decnet.distros import all_distros, DISTROS + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +APT_COMPATIBLE = { + "debian:bookworm-slim", + "ubuntu:22.04", + "ubuntu:20.04", + "kalilinux/kali-rolling", +} + +BUILD_SERVICES = [ + "http", "rdp", "smb", "ftp", "pop3", "imap", + "mysql", "mssql", "redis", "mongodb", "postgres", + "ldap", "vnc", "docker_api", "k8s", "sip", + "mqtt", "llmnr", "snmp", "tftp", +] + +UPSTREAM_SERVICES = ["ssh", "telnet", "smtp", "elasticsearch", "conpot"] + + +def _make_config(services, distro="debian", base_image=None, build_base=None): + profile = DISTROS[distro] + decky = DeckyConfig( + name="decky-01", + ip="10.0.0.10", + services=services, + distro=distro, + base_image=base_image or profile.image, + build_base=build_base or profile.build_base, + hostname="test-host", + ) + return DecnetConfig( + mode="unihost", + interface="eth0", + subnet="10.0.0.0/24", + gateway="10.0.0.1", + deckies=[decky], + ) + + +# --------------------------------------------------------------------------- +# BASE_IMAGE injection — build services +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("svc", BUILD_SERVICES) +def test_build_service_gets_base_image_arg(svc): + """Every build service must have BASE_IMAGE injected in compose args.""" + config = _make_config([svc], distro="debian") + compose = generate_compose(config) + key = f"decky-01-{svc}" + fragment = compose["services"][key] + assert "build" in fragment, f"{svc}: missing 'build' key" + assert "args" in fragment["build"], f"{svc}: build section missing 'args'" + assert "BASE_IMAGE" in fragment["build"]["args"], f"{svc}: BASE_IMAGE not in args" + + +@pytest.mark.parametrize("distro,expected_build_base", [ + ("debian", "debian:bookworm-slim"), + ("ubuntu22", "ubuntu:22.04"), + ("ubuntu20", "ubuntu:20.04"), + ("kali", "kalilinux/kali-rolling"), + ("rocky9", "debian:bookworm-slim"), + ("alpine", "debian:bookworm-slim"), +]) +def test_build_service_base_image_matches_distro(distro, expected_build_base): + """BASE_IMAGE arg must match the distro's build_base.""" + config = _make_config(["http"], distro=distro) + compose = generate_compose(config) + fragment = compose["services"]["decky-01-http"] + assert fragment["build"]["args"]["BASE_IMAGE"] == expected_build_base + + +# --------------------------------------------------------------------------- +# BASE_IMAGE NOT injected for upstream-image services +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("svc", UPSTREAM_SERVICES) +def test_upstream_service_has_no_build_section(svc): + """Upstream-image services must not receive a build section or BASE_IMAGE.""" + config = _make_config([svc]) + compose = generate_compose(config) + fragment = compose["services"][f"decky-01-{svc}"] + assert "build" not in fragment + assert "image" in fragment + + +# --------------------------------------------------------------------------- +# Base container uses distro image, not build_base +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("distro", list(DISTROS.keys())) +def test_base_container_uses_full_distro_image(distro): + """The IP-holder base container must use distro.image, not build_base.""" + config = _make_config(["ssh"], distro=distro) + compose = generate_compose(config) + base = compose["services"]["decky-01"] + expected = DISTROS[distro].image + assert base["image"] == expected, ( + f"distro={distro}: base container image '{base['image']}' != '{expected}'" + ) + + +# --------------------------------------------------------------------------- +# Distro profile — build_base is always apt-compatible +# --------------------------------------------------------------------------- + +def test_all_distros_have_build_base(): + for slug, profile in all_distros().items(): + assert profile.build_base, f"Distro '{slug}' has empty build_base" + + +def test_all_distro_build_bases_are_apt_compatible(): + for slug, profile in all_distros().items(): + assert profile.build_base in APT_COMPATIBLE, ( + f"Distro '{slug}' build_base '{profile.build_base}' is not apt-compatible. " + f"Allowed: {APT_COMPATIBLE}" + ) + + +# --------------------------------------------------------------------------- +# Heterogeneity — multiple deckies with different distros get different images +# --------------------------------------------------------------------------- + +def test_multiple_deckies_different_build_bases(): + """A multi-decky deployment with ubuntu22 and debian must differ in BASE_IMAGE.""" + deckies = [ + DeckyConfig( + name="decky-01", ip="10.0.0.10", + services=["http"], distro="debian", + base_image="debian:bookworm-slim", build_base="debian:bookworm-slim", + hostname="host-01", + ), + DeckyConfig( + name="decky-02", ip="10.0.0.11", + services=["http"], distro="ubuntu22", + base_image="ubuntu:22.04", build_base="ubuntu:22.04", + hostname="host-02", + ), + ] + config = DecnetConfig( + mode="unihost", interface="eth0", + subnet="10.0.0.0/24", gateway="10.0.0.1", + deckies=deckies, + ) + compose = generate_compose(config) + + base_img_01 = compose["services"]["decky-01-http"]["build"]["args"]["BASE_IMAGE"] + base_img_02 = compose["services"]["decky-02-http"]["build"]["args"]["BASE_IMAGE"] + + assert base_img_01 == "debian:bookworm-slim" + assert base_img_02 == "ubuntu:22.04" + assert base_img_01 != base_img_02