Wire all 25 services into --randomize-services and add heterogeneous OS fingerprints

- Replace hardcoded ALL_SERVICE_NAMES=[5 services] in cli.py with
  _all_service_names() pulling dynamically from the plugin registry;
  randomize-services now draws from all 25 registered honeypots
- Add build_base field to DistroProfile: apt-compatible image for service
  Dockerfiles (ubuntu22/ubuntu20/kali get their own; others fall back to
  debian:bookworm-slim since Dockerfiles use apt-get)
- Add build_base to DeckyConfig; propagate from distro in _build_deckies
  and _build_deckies_from_ini
- Inject BASE_IMAGE build arg in composer.py for every build-based service
  so each decky's containers reflect its assigned distro
- Update all 21 service Dockerfiles: FROM debian:bookworm-slim →
  ARG BASE_IMAGE=debian:bookworm-slim / FROM ${BASE_IMAGE}
- Add tests/test_cli_service_pool.py and tests/test_composer.py (306 total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 00:18:16 -03:00
parent e42fcab760
commit 7006ed1308
27 changed files with 320 additions and 33 deletions

View File

@@ -32,7 +32,9 @@ app = typer.Typer(
) )
console = Console() 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( def _resolve_distros(
@@ -71,11 +73,12 @@ def _build_deckies(
if services_explicit: if services_explicit:
svc_list = services_explicit svc_list = services_explicit
elif randomize_services: 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 attempts = 0
while True: while True:
count = random.randint(1, min(3, len(ALL_SERVICE_NAMES))) count = random.randint(1, min(3, len(svc_pool)))
chosen = frozenset(random.sample(ALL_SERVICE_NAMES, count)) chosen = frozenset(random.sample(svc_pool, count))
attempts += 1 attempts += 1
if chosen not in used_combos or attempts > 20: if chosen not in used_combos or attempts > 20:
break break
@@ -92,6 +95,7 @@ def _build_deckies(
services=svc_list, services=svc_list,
distro=distro.slug, distro=distro.slug,
base_image=distro.image, base_image=distro.image,
build_base=distro.build_base,
hostname=hostname, hostname=hostname,
) )
) )
@@ -136,18 +140,19 @@ def _build_deckies_from_ini(
) )
if spec.services: 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] unknown = [s for s in spec.services if s not in known]
if unknown: if unknown:
console.print( console.print(
f"[red]Unknown service(s) in [{spec.name}]: {unknown}. " f"[red]Unknown service(s) in [{spec.name}]: {unknown}. "
f"Available: {ALL_SERVICE_NAMES}[/]" f"Available: {_all_service_names()}[/]"
) )
raise typer.Exit(1) raise typer.Exit(1)
svc_list = spec.services svc_list = spec.services
elif randomize: elif randomize:
count = random.randint(1, min(3, len(ALL_SERVICE_NAMES))) svc_pool = _all_service_names()
svc_list = random.sample(ALL_SERVICE_NAMES, count) count = random.randint(1, min(3, len(svc_pool)))
svc_list = random.sample(svc_pool, count)
else: else:
console.print( console.print(
f"[red]Decky '[{spec.name}]' has no services= in config. " f"[red]Decky '[{spec.name}]' has no services= in config. "
@@ -161,6 +166,7 @@ def _build_deckies_from_ini(
services=svc_list, services=svc_list,
distro=distro.slug, distro=distro.slug,
base_image=distro.image, base_image=distro.image,
build_base=distro.build_base,
hostname=hostname, hostname=hostname,
)) ))
return deckies return deckies
@@ -225,10 +231,10 @@ def deploy(
services_list = [s.strip() for s in services.split(",")] if services else None services_list = [s.strip() for s in services.split(",")] if services else None
if services_list: 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] unknown = [s for s in services_list if s not in known]
if unknown: 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) raise typer.Exit(1)
if not services_list and not randomize_services: if not services_list and not randomize_services:

View File

@@ -48,6 +48,11 @@ def generate_compose(config: DecnetConfig) -> dict:
svc = get_service(svc_name) svc = get_service(svc_name)
fragment = svc.compose_fragment(decky.name, log_target=config.log_target) 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.setdefault("environment", {})
fragment["environment"]["HOSTNAME"] = decky.hostname fragment["environment"]["HOSTNAME"] = decky.hostname

View File

@@ -23,7 +23,8 @@ class DeckyConfig(BaseModel):
ip: str ip: str
services: list[str] services: list[str]
distro: str # slug from distros.DISTROS, e.g. "debian", "ubuntu22" 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 hostname: str
@field_validator("services") @field_validator("services")

View File

@@ -12,9 +12,10 @@ from dataclasses import dataclass
@dataclass(frozen=True) @dataclass(frozen=True)
class DistroProfile: class DistroProfile:
slug: str # CLI-facing identifier, e.g. "debian", "rocky9" 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 display_name: str # Human-readable label shown in tables
hostname_style: str # "generic" | "rhel" | "minimal" | "rolling" hostname_style: str # "generic" | "rhel" | "minimal" | "rolling"
build_base: str # apt-compatible image for service Dockerfiles (FROM ${BASE_IMAGE})
DISTROS: dict[str, DistroProfile] = { DISTROS: dict[str, DistroProfile] = {
@@ -23,54 +24,63 @@ DISTROS: dict[str, DistroProfile] = {
image="debian:bookworm-slim", image="debian:bookworm-slim",
display_name="Debian 12 (Bookworm)", display_name="Debian 12 (Bookworm)",
hostname_style="generic", hostname_style="generic",
build_base="debian:bookworm-slim",
), ),
"ubuntu22": DistroProfile( "ubuntu22": DistroProfile(
slug="ubuntu22", slug="ubuntu22",
image="ubuntu:22.04", image="ubuntu:22.04",
display_name="Ubuntu 22.04 LTS (Jammy)", display_name="Ubuntu 22.04 LTS (Jammy)",
hostname_style="generic", hostname_style="generic",
build_base="ubuntu:22.04",
), ),
"ubuntu20": DistroProfile( "ubuntu20": DistroProfile(
slug="ubuntu20", slug="ubuntu20",
image="ubuntu:20.04", image="ubuntu:20.04",
display_name="Ubuntu 20.04 LTS (Focal)", display_name="Ubuntu 20.04 LTS (Focal)",
hostname_style="generic", hostname_style="generic",
build_base="ubuntu:20.04",
), ),
"rocky9": DistroProfile( "rocky9": DistroProfile(
slug="rocky9", slug="rocky9",
image="rockylinux:9-minimal", image="rockylinux:9-minimal",
display_name="Rocky Linux 9", display_name="Rocky Linux 9",
hostname_style="rhel", hostname_style="rhel",
build_base="debian:bookworm-slim", # Dockerfiles use apt-get; fall back to debian
), ),
"centos7": DistroProfile( "centos7": DistroProfile(
slug="centos7", slug="centos7",
image="centos:7", image="centos:7",
display_name="CentOS 7", display_name="CentOS 7",
hostname_style="rhel", hostname_style="rhel",
build_base="debian:bookworm-slim", # Dockerfiles use apt-get; fall back to debian
), ),
"alpine": DistroProfile( "alpine": DistroProfile(
slug="alpine", slug="alpine",
image="alpine:3.19", image="alpine:3.19",
display_name="Alpine Linux 3.19", display_name="Alpine Linux 3.19",
hostname_style="minimal", hostname_style="minimal",
build_base="debian:bookworm-slim", # Dockerfiles use apt-get; fall back to debian
), ),
"fedora": DistroProfile( "fedora": DistroProfile(
slug="fedora", slug="fedora",
image="fedora:39", image="fedora:39",
display_name="Fedora 39", display_name="Fedora 39",
hostname_style="rhel", hostname_style="rhel",
build_base="debian:bookworm-slim", # Dockerfiles use apt-get; fall back to debian
), ),
"kali": DistroProfile( "kali": DistroProfile(
slug="kali", slug="kali",
image="kalilinux/kali-rolling", image="kalilinux/kali-rolling",
display_name="Kali Linux (Rolling)", display_name="Kali Linux (Rolling)",
hostname_style="rolling", hostname_style="rolling",
build_base="kalilinux/kali-rolling", # Debian-based, apt-get compatible
), ),
"arch": DistroProfile( "arch": DistroProfile(
slug="arch", slug="arch",
image="archlinux:latest", image="archlinux:latest",
display_name="Arch Linux", display_name="Arch Linux",
hostname_style="rolling", hostname_style="rolling",
build_base="debian:bookworm-slim", # Dockerfiles use apt-get; fall back to debian
), ),
} }

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv \ python3 python3-pip python3-venv \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \ python3 python3-pip \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \ python3 python3-pip \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \ python3 python3-pip \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \ python3 python3-pip \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \ python3 python3-pip \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \ python3 python3-pip \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \

View File

@@ -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"]

163
tests/test_composer.py Normal file
View File

@@ -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