merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
165
decnet/topology/compose.py
Normal file
165
decnet/topology/compose.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Compose-file generator for a MazeNET topology.
|
||||
|
||||
Produces a ``docker-compose.yml`` dict given a hydrated topology
|
||||
(the output of :func:`decnet.topology.persistence.hydrate`). The
|
||||
compose file references each LAN as an ``external: true`` network —
|
||||
the deployer creates the Docker bridge networks via the SDK before
|
||||
invoking ``docker compose up``.
|
||||
|
||||
Layout:
|
||||
* Each decky has a "base" container holding the LAN IPs. Multi-homed
|
||||
(bridge) deckies list every LAN they belong to under ``networks``
|
||||
with the per-LAN ``ipv4_address``.
|
||||
* Bridge deckies with ``forwards_l3=True`` get ``net.ipv4.ip_forward=1``
|
||||
baked in via compose ``sysctls`` plus ``NET_ADMIN`` in ``cap_add``.
|
||||
* Service containers share the base namespace via
|
||||
``network_mode: service:<base>``, matching the flat composer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from decnet.services.registry import get_service
|
||||
|
||||
_DEFAULT_BASE_IMAGE = "debian:bookworm-slim"
|
||||
|
||||
_DOCKER_LOGGING = {
|
||||
"driver": "json-file",
|
||||
"options": {"max-size": "10m", "max-file": "5"},
|
||||
}
|
||||
|
||||
|
||||
def _network_name(topology_id: str, lan_name: str) -> str:
|
||||
"""Docker network name for a given (topology, LAN) pair."""
|
||||
return f"decnet_t_{topology_id[:8]}_{lan_name.lower()}"
|
||||
|
||||
|
||||
def _container_name(topology_id: str, decky_name: str) -> str:
|
||||
"""Container name for a decky base in a topology."""
|
||||
return f"decnet_t_{topology_id[:8]}_{decky_name}"
|
||||
|
||||
|
||||
def generate_topology_compose(hydrated: dict[str, Any]) -> dict:
|
||||
"""Build the compose dict for a hydrated topology.
|
||||
|
||||
``hydrated`` is the shape returned by
|
||||
:func:`decnet.topology.persistence.hydrate`.
|
||||
"""
|
||||
topology = hydrated["topology"]
|
||||
topology_id = topology["id"]
|
||||
lans = hydrated["lans"]
|
||||
deckies = hydrated["deckies"]
|
||||
|
||||
lan_by_name = {lan["name"]: lan for lan in lans}
|
||||
|
||||
services: dict[str, dict] = {}
|
||||
|
||||
for decky in deckies:
|
||||
cfg = decky["decky_config"]
|
||||
name = cfg["name"]
|
||||
ips_by_lan: dict[str, str] = cfg["ips_by_lan"]
|
||||
forwards_l3: bool = cfg.get("forwards_l3", False)
|
||||
service_config: dict[str, dict] = cfg.get("service_config", {}) or {}
|
||||
svc_names: list[str] = decky["services"]
|
||||
|
||||
base_key = name
|
||||
nets: dict[str, dict] = {}
|
||||
for lan_name, ip in ips_by_lan.items():
|
||||
if lan_name not in lan_by_name:
|
||||
raise ValueError(
|
||||
f"decky {name!r} references unknown LAN {lan_name!r}"
|
||||
)
|
||||
nets[_network_name(topology_id, lan_name)] = {"ipv4_address": ip}
|
||||
|
||||
base: dict = {
|
||||
"image": _DEFAULT_BASE_IMAGE,
|
||||
"container_name": _container_name(topology_id, name),
|
||||
"hostname": name,
|
||||
"command": ["sleep", "infinity"],
|
||||
"restart": "unless-stopped",
|
||||
"networks": nets,
|
||||
"cap_add": ["NET_ADMIN"],
|
||||
"logging": _DOCKER_LOGGING,
|
||||
# Labels let the host collector discover topology containers
|
||||
# without consulting decnet-state.json (which only knows about
|
||||
# legacy fleet deckies). See decnet/collector/worker.py.
|
||||
"labels": {
|
||||
"decnet.topology.id": topology_id,
|
||||
"decnet.topology.decky": name,
|
||||
"decnet.topology.role": "base",
|
||||
},
|
||||
}
|
||||
if forwards_l3:
|
||||
base["sysctls"] = {"net.ipv4.ip_forward": 1}
|
||||
# Gateway decky — publish its service ports on the host so
|
||||
# attackers can reach the DMZ via the host's public IP.
|
||||
# Service containers share this base's namespace (see below),
|
||||
# so ports declared here expose every service's listener.
|
||||
published: list[str] = []
|
||||
for svc_name in svc_names:
|
||||
svc = get_service(svc_name)
|
||||
if svc is None or svc.fleet_singleton:
|
||||
continue
|
||||
for port in svc.ports:
|
||||
published.append(f"{port}:{port}")
|
||||
if published:
|
||||
base["ports"] = published
|
||||
|
||||
services[base_key] = base
|
||||
|
||||
for svc_name in svc_names:
|
||||
svc = get_service(svc_name)
|
||||
if svc is None or svc.fleet_singleton:
|
||||
continue
|
||||
fragment = svc.compose_fragment(
|
||||
name, service_cfg=service_config.get(svc_name, {})
|
||||
)
|
||||
if "build" in fragment:
|
||||
fragment["build"].setdefault("args", {}).setdefault(
|
||||
"BASE_IMAGE", _DEFAULT_BASE_IMAGE
|
||||
)
|
||||
fragment.setdefault("environment", {})
|
||||
fragment["environment"]["HOSTNAME"] = name
|
||||
fragment["network_mode"] = f"service:{base_key}"
|
||||
fragment["depends_on"] = [base_key]
|
||||
fragment.pop("hostname", None)
|
||||
fragment.pop("networks", None)
|
||||
fragment["logging"] = _DOCKER_LOGGING
|
||||
# ``decnet.topology.service=true`` is the marker the collector
|
||||
# filters on — without it, log streams for this container are
|
||||
# never attached.
|
||||
labels = dict(fragment.get("labels") or {})
|
||||
labels.update({
|
||||
"decnet.topology.id": topology_id,
|
||||
"decnet.topology.decky": name,
|
||||
"decnet.topology.service_name": svc_name,
|
||||
"decnet.topology.service": "true",
|
||||
})
|
||||
fragment["labels"] = labels
|
||||
services[f"{name}-{svc_name}"] = fragment
|
||||
|
||||
networks: dict[str, dict] = {
|
||||
_network_name(topology_id, lan["name"]): {
|
||||
"external": True,
|
||||
"name": _network_name(topology_id, lan["name"]),
|
||||
}
|
||||
for lan in lans
|
||||
}
|
||||
|
||||
return {
|
||||
"version": "3.8",
|
||||
"services": services,
|
||||
"networks": networks,
|
||||
}
|
||||
|
||||
|
||||
def write_topology_compose(hydrated: dict[str, Any], output_path: Path) -> Path:
|
||||
"""Write the compose dict for a hydrated topology and return the path."""
|
||||
data = generate_topology_compose(hydrated)
|
||||
output_path.write_text(
|
||||
yaml.dump(data, default_flow_style=False, sort_keys=False)
|
||||
)
|
||||
return output_path
|
||||
Reference in New Issue
Block a user