Files
DECNET/decnet/composer.py
anti 817ce32e6d fix(collector): label-based fleet container discovery
The events watcher's start-event filter previously called
_load_service_container_names(), which reads decnet-state.json on
every event. decnet deploy writes that state file out-of-band
with docker compose up, so a container's start event could
arrive before the state was committed — the watcher then dropped
the event silently and never tailed the container's stdout. The
visible symptom was an empty Credentials view (and Logs/Bounty)
after a fresh deploy until the collector was manually restarted.

Fix: stamp decnet.fleet.{service,decky,service_name} labels on
every fleet service container at compose-time, and let the
collector recognize either the fleet or topology label without
touching the state file. The state-file name match remains as a
fallback for legacy containers that predate the new labels.
2026-04-25 08:11:21 -04:00

128 lines
4.6 KiB
Python

"""
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:<base>"`. 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>".
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
# Stamp DECNET ownership labels so the collector's docker-events
# watcher can identify newly-started containers without consulting
# decnet-state.json (which is written and read out-of-band with
# `docker compose up`, leaving a race window where freshly started
# containers were silently ignored).
labels = dict(fragment.get("labels") or {})
labels.update({
"decnet.fleet.service": "true",
"decnet.fleet.decky": decky.name,
"decnet.fleet.service_name": svc_name,
})
fragment["labels"] = labels
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