""" Generates a docker-compose.yml from a DecnetConfig. Network model: Each decky gets ONE "base" container that holds the MACVLAN IP. All service containers for that decky share the base's network namespace via `network_mode: "service:"`. From the outside, every service on a given decky appears to come from the same IP — exactly like a real host. Logging model: Service containers write RFC 5424 lines to stdout. Docker captures them via the json-file driver. The host-side collector (decnet.web.collector) streams those logs and writes them to the host log file for the ingester and rsyslog to consume. No bind mounts or shared volumes are needed. """ from pathlib import Path import yaml from decnet.config import DecnetConfig from decnet.network import MACVLAN_NETWORK_NAME from decnet.os_fingerprint import get_os_sysctls from decnet.services.registry import get_service _DOCKER_LOGGING = { "driver": "json-file", "options": { "max-size": "10m", "max-file": "5", }, } def generate_compose(config: DecnetConfig) -> dict: """Build and return the full docker-compose data structure.""" services: dict = {} for decky in config.deckies: base_key = decky.name # e.g. "decky-01" # --- Base container: owns the MACVLAN IP, runs nothing but sleep --- base: dict = { "image": decky.base_image, "container_name": base_key, "hostname": decky.hostname, "command": ["sleep", "infinity"], "restart": "unless-stopped", "networks": { MACVLAN_NETWORK_NAME: { "ipv4_address": decky.ip, } }, } # Inject TCP/IP stack sysctls to spoof the claimed OS fingerprint. # Only the base container needs this — service containers inherit the # same network namespace via network_mode: "service:". base["sysctls"] = get_os_sysctls(decky.nmap_os) base["cap_add"] = ["NET_ADMIN"] services[base_key] = base # --- Service containers: share base network namespace --- for svc_name in decky.services: svc = get_service(svc_name) if svc.fleet_singleton: continue svc_cfg = decky.service_config.get(svc_name, {}) fragment = svc.compose_fragment(decky.name, service_cfg=svc_cfg) # Inject the per-decky base image into build services so containers # vary by distro and don't all fingerprint as debian:bookworm-slim. # Services that need a fixed upstream image (e.g. conpot) can pre-set # build.args.BASE_IMAGE in their compose_fragment() to opt out. if "build" in fragment: args = fragment["build"].setdefault("args", {}) args.setdefault("BASE_IMAGE", decky.build_base) fragment.setdefault("environment", {}) fragment["environment"]["HOSTNAME"] = decky.hostname # Share the base container's network — no own IP needed fragment["network_mode"] = f"service:{base_key}" fragment["depends_on"] = [base_key] # hostname must not be set when using network_mode fragment.pop("hostname", None) fragment.pop("networks", None) # Rotate Docker logs so disk usage is bounded fragment["logging"] = _DOCKER_LOGGING services[f"{decky.name}-{svc_name}"] = fragment # Network definitions networks: dict = { MACVLAN_NETWORK_NAME: { "external": True, # created by network.py before compose up } } return { "version": "3.8", "services": services, "networks": networks, } def write_compose(config: DecnetConfig, output_path: Path) -> Path: """Write the docker-compose.yml to output_path and return it.""" data = generate_compose(config) output_path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False)) return output_path