Add multi-distro support for deckies

Introduces DistroProfile catalog (9 distros: Debian, Ubuntu 20/22,
Rocky 9, CentOS 7, Alpine, Fedora, Kali, Arch) with distro-styled
hostname generation. Adds --distro and --randomize-distros CLI flags,
a `decnet distros` listing command, and fixes composer.py which was
ignoring per-decky base_image in favour of a hardcoded Debian constant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 19:05:30 -03:00
parent 3e98c71ca4
commit 65e3ea6b08
4 changed files with 186 additions and 32 deletions

View File

@@ -16,11 +16,11 @@ from rich.console import Console
from rich.table import Table from rich.table import Table
from decnet.config import ( from decnet.config import (
BASE_IMAGES,
DeckyConfig, DeckyConfig,
DecnetConfig, DecnetConfig,
random_hostname, random_hostname,
) )
from decnet.distros import all_distros, get_distro, random_distro
from decnet.ini_loader import IniConfig, load_ini from decnet.ini_loader import IniConfig, load_ini
from decnet.network import detect_interface, detect_subnet, allocate_ips, get_host_ip from decnet.network import detect_interface, detect_subnet, allocate_ips, get_host_ip
from decnet.services.registry import all_services from decnet.services.registry import all_services
@@ -35,23 +35,42 @@ console = Console()
ALL_SERVICE_NAMES = ["ssh", "smb", "rdp", "http", "ftp"] ALL_SERVICE_NAMES = ["ssh", "smb", "rdp", "http", "ftp"]
def _resolve_distros(
distros_explicit: list[str] | None,
randomize_distros: bool,
n: int,
) -> list[str]:
"""Return a list of n distro slugs based on CLI flags."""
if distros_explicit:
# Round-robin the provided list to fill n slots
return [distros_explicit[i % len(distros_explicit)] for i in range(n)]
if randomize_distros:
return [random_distro().slug for _ in range(n)]
# Default: cycle through all distros to maximize heterogeneity
slugs = list(all_distros().keys())
return [slugs[i % len(slugs)] for i in range(n)]
def _build_deckies( def _build_deckies(
n: int, n: int,
ips: list[str], ips: list[str],
services_explicit: list[str] | None, services_explicit: list[str] | None,
randomize: bool, randomize_services: bool,
distros_explicit: list[str] | None = None,
randomize_distros: bool = False,
) -> list[DeckyConfig]: ) -> list[DeckyConfig]:
deckies = [] deckies = []
used_combos: set[frozenset] = set() used_combos: set[frozenset] = set()
distro_slugs = _resolve_distros(distros_explicit, randomize_distros, n)
for i, ip in enumerate(ips): for i, ip in enumerate(ips):
name = f"decky-{i + 1:02d}" name = f"decky-{i + 1:02d}"
base_image = BASE_IMAGES[i % len(BASE_IMAGES)] distro = get_distro(distro_slugs[i])
hostname = random_hostname() hostname = random_hostname(distro.slug)
if services_explicit: if services_explicit:
svc_list = services_explicit svc_list = services_explicit
elif randomize: elif randomize_services:
# Pick 1-3 random services, try to avoid exact duplicates # Pick 1-3 random services, try to avoid exact duplicates
attempts = 0 attempts = 0
while True: while True:
@@ -71,7 +90,8 @@ def _build_deckies(
name=name, name=name,
ip=ip, ip=ip,
services=svc_list, services=svc_list,
base_image=base_image, distro=distro.slug,
base_image=distro.image,
hostname=hostname, hostname=hostname,
) )
) )
@@ -103,10 +123,11 @@ def _build_deckies_from_ini(
auto_pool = (str(addr) for addr in net.hosts() if addr not in reserved) auto_pool = (str(addr) for addr in net.hosts() if addr not in reserved)
distro_slugs = _resolve_distros(None, randomize, len(ini.deckies))
deckies: list[DeckyConfig] = [] deckies: list[DeckyConfig] = []
for i, spec in enumerate(ini.deckies): for i, spec in enumerate(ini.deckies):
base_image = BASE_IMAGES[i % len(BASE_IMAGES)] distro = get_distro(distro_slugs[i])
hostname = random_hostname() hostname = random_hostname(distro.slug)
ip = spec.ip or next(auto_pool, None) ip = spec.ip or next(auto_pool, None)
if ip is None: if ip is None:
@@ -125,9 +146,8 @@ def _build_deckies_from_ini(
raise typer.Exit(1) raise typer.Exit(1)
svc_list = spec.services svc_list = spec.services
elif randomize: elif randomize:
import random as _random count = random.randint(1, min(3, len(ALL_SERVICE_NAMES)))
count = _random.randint(1, min(3, len(ALL_SERVICE_NAMES))) svc_list = random.sample(ALL_SERVICE_NAMES, count)
svc_list = _random.sample(ALL_SERVICE_NAMES, 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. "
@@ -139,7 +159,8 @@ def _build_deckies_from_ini(
name=spec.name, name=spec.name,
ip=ip, ip=ip,
services=svc_list, services=svc_list,
base_image=base_image, distro=distro.slug,
base_image=distro.image,
hostname=hostname, hostname=hostname,
)) ))
return deckies return deckies
@@ -154,6 +175,8 @@ def deploy(
ip_start: Optional[str] = typer.Option(None, "--ip-start", help="First decky IP (auto if omitted)"), ip_start: Optional[str] = typer.Option(None, "--ip-start", help="First decky IP (auto if omitted)"),
services: Optional[str] = typer.Option(None, "--services", help="Comma-separated services, e.g. ssh,smb,rdp"), services: Optional[str] = typer.Option(None, "--services", help="Comma-separated services, e.g. ssh,smb,rdp"),
randomize_services: bool = typer.Option(False, "--randomize-services", help="Assign random services to each decky"), randomize_services: bool = typer.Option(False, "--randomize-services", help="Assign random services to each decky"),
distro: Optional[str] = typer.Option(None, "--distro", help="Comma-separated distro slugs, e.g. debian,ubuntu22,rocky9"),
randomize_distros: bool = typer.Option(False, "--randomize-distros", help="Assign a random distro to each decky"),
log_target: Optional[str] = typer.Option(None, "--log-target", help="Forward logs to ip:port (e.g. 192.168.1.5:5140)"), log_target: Optional[str] = typer.Option(None, "--log-target", help="Forward logs to ip:port (e.g. 192.168.1.5:5140)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Generate compose file without starting containers"), dry_run: bool = typer.Option(False, "--dry-run", help="Generate compose file without starting containers"),
no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild all images, ignoring Docker layer cache"), no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild all images, ignoring Docker layer cache"),
@@ -223,8 +246,20 @@ def deploy(
console.print(f"[dim]Interface:[/] {iface} [dim]Subnet:[/] {subnet_cidr} " console.print(f"[dim]Interface:[/] {iface} [dim]Subnet:[/] {subnet_cidr} "
f"[dim]Gateway:[/] {effective_gateway} [dim]Host IP:[/] {host_ip}") f"[dim]Gateway:[/] {effective_gateway} [dim]Host IP:[/] {host_ip}")
distros_list = [d.strip() for d in distro.split(",")] if distro else None
if distros_list:
try:
for slug in distros_list:
get_distro(slug)
except ValueError as e:
console.print(f"[red]{e}[/]")
raise typer.Exit(1)
ips = allocate_ips(subnet_cidr, effective_gateway, host_ip, deckies, ip_start) ips = allocate_ips(subnet_cidr, effective_gateway, host_ip, deckies, ip_start)
decky_configs = _build_deckies(deckies, ips, services_list, randomize_services) decky_configs = _build_deckies(
deckies, ips, services_list, randomize_services,
distros_explicit=distros_list, randomize_distros=randomize_distros,
)
effective_log_target = log_target effective_log_target = log_target
config = DecnetConfig( config = DecnetConfig(
@@ -278,3 +313,15 @@ def list_services() -> None:
for name, svc in sorted(svcs.items()): for name, svc in sorted(svcs.items()):
table.add_row(name, ", ".join(str(p) for p in svc.ports), svc.default_image) table.add_row(name, ", ".join(str(p) for p in svc.ports), svc.default_image)
console.print(table) console.print(table)
@app.command(name="distros")
def list_distros() -> None:
"""List all available OS distro profiles for deckies."""
table = Table(title="Available Distro Profiles", show_lines=True)
table.add_column("Slug", style="bold cyan")
table.add_column("Display Name")
table.add_column("Docker Image", style="dim")
for slug, profile in sorted(all_distros().items()):
table.add_row(slug, profile.display_name, profile.image)
console.print(table)

View File

@@ -18,9 +18,6 @@ from decnet.services.registry import get_service
_LOG_NETWORK = "decnet_logs" _LOG_NETWORK = "decnet_logs"
# Minimal image for the base container — just needs to stay alive.
_BASE_IMAGE = "debian:bookworm-slim"
def generate_compose(config: DecnetConfig) -> dict: def generate_compose(config: DecnetConfig) -> dict:
"""Build and return the full docker-compose data structure.""" """Build and return the full docker-compose data structure."""
@@ -31,7 +28,7 @@ def generate_compose(config: DecnetConfig) -> dict:
# --- Base container: owns the MACVLAN IP, runs nothing but sleep --- # --- Base container: owns the MACVLAN IP, runs nothing but sleep ---
base: dict = { base: dict = {
"image": _BASE_IMAGE, "image": decky.base_image,
"container_name": base_key, "container_name": base_key,
"hostname": decky.hostname, "hostname": decky.hostname,
"command": ["sleep", "infinity"], "command": ["sleep", "infinity"],

View File

@@ -4,35 +4,26 @@ State is persisted to decnet-state.json in the working directory.
""" """
import json import json
import random
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from decnet.distros import random_hostname as _random_hostname
STATE_FILE = Path("decnet-state.json") STATE_FILE = Path("decnet-state.json")
BASE_IMAGES = [
"debian:bookworm-slim",
"ubuntu:22.04",
]
DECKY_NAME_WORDS = [ def random_hostname(distro_slug: str = "debian") -> str:
"alpha", "bravo", "charlie", "delta", "echo", return _random_hostname(distro_slug)
"foxtrot", "golf", "hotel", "india", "juliet",
"kilo", "lima", "mike", "nova", "oscar",
]
def random_hostname() -> str:
return f"SRV-{random.choice(DECKY_NAME_WORDS).upper()}-{random.randint(10, 99)}"
class DeckyConfig(BaseModel): class DeckyConfig(BaseModel):
name: str name: str
ip: str ip: str
services: list[str] services: list[str]
base_image: str distro: str # slug from distros.DISTROS, e.g. "debian", "ubuntu22"
base_image: str # resolved Docker image tag
hostname: str hostname: str
@field_validator("services") @field_validator("services")

119
decnet/distros.py Normal file
View File

@@ -0,0 +1,119 @@
"""
Distro profiles for DECNET deckies.
Each profile maps a human-readable slug to a Docker image and OS metadata used
to make deckies look like heterogeneous real machines on the LAN.
"""
import random
from dataclasses import dataclass
@dataclass(frozen=True)
class DistroProfile:
slug: str # CLI-facing identifier, e.g. "debian", "rocky9"
image: str # Docker image tag
display_name: str # Human-readable label shown in tables
hostname_style: str # "generic" | "rhel" | "minimal" | "rolling"
DISTROS: dict[str, DistroProfile] = {
"debian": DistroProfile(
slug="debian",
image="debian:bookworm-slim",
display_name="Debian 12 (Bookworm)",
hostname_style="generic",
),
"ubuntu22": DistroProfile(
slug="ubuntu22",
image="ubuntu:22.04",
display_name="Ubuntu 22.04 LTS (Jammy)",
hostname_style="generic",
),
"ubuntu20": DistroProfile(
slug="ubuntu20",
image="ubuntu:20.04",
display_name="Ubuntu 20.04 LTS (Focal)",
hostname_style="generic",
),
"rocky9": DistroProfile(
slug="rocky9",
image="rockylinux:9-minimal",
display_name="Rocky Linux 9",
hostname_style="rhel",
),
"centos7": DistroProfile(
slug="centos7",
image="centos:7",
display_name="CentOS 7",
hostname_style="rhel",
),
"alpine": DistroProfile(
slug="alpine",
image="alpine:3.19",
display_name="Alpine Linux 3.19",
hostname_style="minimal",
),
"fedora": DistroProfile(
slug="fedora",
image="fedora:39",
display_name="Fedora 39",
hostname_style="rhel",
),
"kali": DistroProfile(
slug="kali",
image="kalilinux/kali-rolling",
display_name="Kali Linux (Rolling)",
hostname_style="rolling",
),
"arch": DistroProfile(
slug="arch",
image="archlinux:latest",
display_name="Arch Linux",
hostname_style="rolling",
),
}
_NAME_WORDS = [
"alpha", "bravo", "charlie", "delta", "echo",
"foxtrot", "golf", "hotel", "india", "juliet",
"kilo", "lima", "mike", "nova", "oscar",
"prod", "web", "db", "mail", "proxy",
"dev", "stage", "backup", "monitor", "files",
]
def random_hostname(distro_slug: str = "debian") -> str:
"""Generate a plausible hostname for the given distro style."""
profile = DISTROS.get(distro_slug)
style = profile.hostname_style if profile else "generic"
word = random.choice(_NAME_WORDS)
num = random.randint(10, 99)
if style == "rhel":
# RHEL/CentOS/Fedora convention: word+num.localdomain
return f"{word}{num}.localdomain"
elif style == "minimal":
return f"{word}-{num}"
elif style == "rolling":
# Kali/Arch: just a word, no suffix
return f"{word}-{random.choice(_NAME_WORDS)}"
else:
# Debian/Ubuntu: SRV-WORD-nn
return f"SRV-{word.upper()}-{num}"
def get_distro(slug: str) -> DistroProfile:
if slug not in DISTROS:
raise ValueError(
f"Unknown distro '{slug}'. Available: {', '.join(sorted(DISTROS))}"
)
return DISTROS[slug]
def random_distro() -> DistroProfile:
return random.choice(list(DISTROS.values()))
def all_distros() -> dict[str, DistroProfile]:
return dict(DISTROS)