dtagdevsec/mailoney and dtagdevsec/elasticpot are unavailable on Docker Hub
("manifest unknown"), causing the entire deployment to abort and cascade-
interrupt all other image pulls.
- Convert smtp and elasticsearch to build services with custom Python
honeypots: smtp emulates Postfix ESMTP (EHLO/AUTH/MAIL/RCPT logging),
elasticsearch emulates ES 7.17 HTTP API (logs recon probes like /_cat/,
/_cluster/, /_nodes/, /_security/)
- Both use ARG BASE_IMAGE so they participate in per-decky distro variation
- Add _compose_with_retry() to deployer: 3 attempts with exponential backoff
(5s → 10s → 20s) for transient network failures; permanent errors
("manifest unknown", "pull access denied") are detected and not retried
- Update test_services.py and test_composer.py: smtp/elasticsearch moved
from UPSTREAM_SERVICES to BUILD_SERVICES (314 tests passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
"""
|
|
Tests for all 25 DECNET service plugins.
|
|
|
|
Covers:
|
|
- Service registration via the plugin registry
|
|
- compose_fragment structure (container_name, restart, image/build)
|
|
- LOG_TARGET propagation for custom-build services
|
|
- dockerfile_context returns Path for build services, None for upstream-image services
|
|
"""
|
|
|
|
import pytest
|
|
from pathlib import Path
|
|
from decnet.services.registry import all_services, get_service
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _fragment(name: str, log_target: str | None = None) -> dict:
|
|
return get_service(name).compose_fragment("test-decky", log_target)
|
|
|
|
|
|
def _is_build_service(name: str) -> bool:
|
|
svc = get_service(name)
|
|
return svc.default_image == "build"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tier 1: upstream-image services
|
|
# ---------------------------------------------------------------------------
|
|
|
|
UPSTREAM_SERVICES = {
|
|
"ssh": ("cowrie/cowrie", [22, 2222]),
|
|
"telnet": ("cowrie/cowrie", [23]),
|
|
"conpot": ("honeynet/conpot", [502, 161, 80]),
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tier 2: custom-build services
|
|
# ---------------------------------------------------------------------------
|
|
|
|
BUILD_SERVICES = {
|
|
"http": ([80, 443], "http"),
|
|
"rdp": ([3389], "rdp"),
|
|
"smb": ([445, 139], "smb"),
|
|
"ftp": ([21], "ftp"),
|
|
"smtp": ([25, 587], "smtp"),
|
|
"elasticsearch": ([9200], "elasticsearch"),
|
|
"pop3": ([110, 995], "pop3"),
|
|
"imap": ([143, 993], "imap"),
|
|
"mysql": ([3306], "mysql"),
|
|
"mssql": ([1433], "mssql"),
|
|
"redis": ([6379], "redis"),
|
|
"mongodb": ([27017], "mongodb"),
|
|
"postgres": ([5432], "postgres"),
|
|
"ldap": ([389, 636], "ldap"),
|
|
"vnc": ([5900], "vnc"),
|
|
"docker_api": ([2375, 2376], "docker_api"),
|
|
"k8s": ([6443, 8080], "k8s"),
|
|
"sip": ([5060], "sip"),
|
|
"mqtt": ([1883], "mqtt"),
|
|
"llmnr": ([5355, 5353], "llmnr"),
|
|
"snmp": ([161], "snmp"),
|
|
"tftp": ([69], "tftp"),
|
|
}
|
|
|
|
ALL_SERVICE_NAMES = list(UPSTREAM_SERVICES) + list(BUILD_SERVICES)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registration tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("name", ALL_SERVICE_NAMES)
|
|
def test_service_registered(name):
|
|
"""Every service must appear in the registry."""
|
|
registry = all_services()
|
|
assert name in registry, f"Service '{name}' not found in registry"
|
|
|
|
|
|
@pytest.mark.parametrize("name", ALL_SERVICE_NAMES)
|
|
def test_service_ports_defined(name):
|
|
"""Every service must declare at least one port."""
|
|
svc = get_service(name)
|
|
assert isinstance(svc.ports, list)
|
|
assert len(svc.ports) >= 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Upstream-image service tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("name,expected", [
|
|
(n, (img, ports)) for n, (img, ports) in UPSTREAM_SERVICES.items()
|
|
])
|
|
def test_upstream_image(name, expected):
|
|
expected_image, _ = expected
|
|
frag = _fragment(name)
|
|
assert frag.get("image") == expected_image
|
|
|
|
|
|
@pytest.mark.parametrize("name", UPSTREAM_SERVICES)
|
|
def test_upstream_no_dockerfile_context(name):
|
|
assert get_service(name).dockerfile_context() is None
|
|
|
|
|
|
@pytest.mark.parametrize("name", UPSTREAM_SERVICES)
|
|
def test_upstream_container_name(name):
|
|
frag = _fragment(name)
|
|
assert frag["container_name"] == f"test-decky-{name.replace('_', '-')}"
|
|
|
|
|
|
@pytest.mark.parametrize("name", UPSTREAM_SERVICES)
|
|
def test_upstream_restart_policy(name):
|
|
frag = _fragment(name)
|
|
assert frag.get("restart") == "unless-stopped"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Build-service tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("name", BUILD_SERVICES)
|
|
def test_build_service_uses_build(name):
|
|
frag = _fragment(name)
|
|
assert "build" in frag, f"Service '{name}' fragment missing 'build' key"
|
|
assert "context" in frag["build"]
|
|
|
|
|
|
@pytest.mark.parametrize("name", BUILD_SERVICES)
|
|
def test_build_service_dockerfile_context_is_path(name):
|
|
ctx = get_service(name).dockerfile_context()
|
|
assert isinstance(ctx, Path), f"Service '{name}' dockerfile_context should return a Path"
|
|
|
|
|
|
@pytest.mark.parametrize("name", BUILD_SERVICES)
|
|
def test_build_service_dockerfile_exists(name):
|
|
ctx = get_service(name).dockerfile_context()
|
|
dockerfile = ctx / "Dockerfile"
|
|
assert dockerfile.exists(), f"Dockerfile missing at {dockerfile}"
|
|
|
|
|
|
@pytest.mark.parametrize("name", BUILD_SERVICES)
|
|
def test_build_service_container_name(name):
|
|
frag = _fragment(name)
|
|
slug = name.replace("_", "-")
|
|
assert frag["container_name"] == f"test-decky-{slug}"
|
|
|
|
|
|
@pytest.mark.parametrize("name", BUILD_SERVICES)
|
|
def test_build_service_restart_policy(name):
|
|
frag = _fragment(name)
|
|
assert frag.get("restart") == "unless-stopped"
|
|
|
|
|
|
@pytest.mark.parametrize("name", BUILD_SERVICES)
|
|
def test_build_service_honeypot_name_env(name):
|
|
frag = _fragment(name)
|
|
env = frag.get("environment", {})
|
|
assert "HONEYPOT_NAME" in env
|
|
assert env["HONEYPOT_NAME"] == "test-decky"
|
|
|
|
|
|
@pytest.mark.parametrize("name", BUILD_SERVICES)
|
|
def test_build_service_log_target_propagated(name):
|
|
frag = _fragment(name, log_target="10.0.0.1:5140")
|
|
env = frag.get("environment", {})
|
|
assert env.get("LOG_TARGET") == "10.0.0.1:5140"
|
|
|
|
|
|
@pytest.mark.parametrize("name", BUILD_SERVICES)
|
|
def test_build_service_no_log_target_by_default(name):
|
|
frag = _fragment(name)
|
|
env = frag.get("environment", {})
|
|
assert "LOG_TARGET" not in env
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Port coverage tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("name,expected", [
|
|
(n, ports) for n, (ports, _) in BUILD_SERVICES.items()
|
|
])
|
|
def test_build_service_ports(name, expected):
|
|
svc = get_service(name)
|
|
assert svc.ports == expected
|
|
|
|
|
|
@pytest.mark.parametrize("name,expected", [
|
|
(n, ports) for n, (_, ports) in UPSTREAM_SERVICES.items()
|
|
])
|
|
def test_upstream_service_ports(name, expected):
|
|
svc = get_service(name)
|
|
assert svc.ports == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry completeness
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_total_service_count():
|
|
"""Sanity check: at least 25 services registered."""
|
|
assert len(all_services()) >= 25
|