From 65e3ea6b085ac4deabab57dad761457b25fd8157 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 3 Apr 2026 19:05:30 -0300 Subject: [PATCH] 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 --- decnet/cli.py | 73 ++++++++++++++++++++++----- decnet/composer.py | 5 +- decnet/config.py | 21 +++----- decnet/distros.py | 119 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 32 deletions(-) create mode 100644 decnet/distros.py diff --git a/decnet/cli.py b/decnet/cli.py index 2b74412..97f75c5 100644 --- a/decnet/cli.py +++ b/decnet/cli.py @@ -16,11 +16,11 @@ from rich.console import Console from rich.table import Table from decnet.config import ( - BASE_IMAGES, DeckyConfig, DecnetConfig, random_hostname, ) +from decnet.distros import all_distros, get_distro, random_distro from decnet.ini_loader import IniConfig, load_ini from decnet.network import detect_interface, detect_subnet, allocate_ips, get_host_ip from decnet.services.registry import all_services @@ -35,23 +35,42 @@ console = Console() 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( n: int, ips: list[str], services_explicit: list[str] | None, - randomize: bool, + randomize_services: bool, + distros_explicit: list[str] | None = None, + randomize_distros: bool = False, ) -> list[DeckyConfig]: deckies = [] used_combos: set[frozenset] = set() + distro_slugs = _resolve_distros(distros_explicit, randomize_distros, n) for i, ip in enumerate(ips): name = f"decky-{i + 1:02d}" - base_image = BASE_IMAGES[i % len(BASE_IMAGES)] - hostname = random_hostname() + distro = get_distro(distro_slugs[i]) + hostname = random_hostname(distro.slug) if services_explicit: svc_list = services_explicit - elif randomize: + elif randomize_services: # Pick 1-3 random services, try to avoid exact duplicates attempts = 0 while True: @@ -71,7 +90,8 @@ def _build_deckies( name=name, ip=ip, services=svc_list, - base_image=base_image, + distro=distro.slug, + base_image=distro.image, 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) + distro_slugs = _resolve_distros(None, randomize, len(ini.deckies)) deckies: list[DeckyConfig] = [] for i, spec in enumerate(ini.deckies): - base_image = BASE_IMAGES[i % len(BASE_IMAGES)] - hostname = random_hostname() + distro = get_distro(distro_slugs[i]) + hostname = random_hostname(distro.slug) ip = spec.ip or next(auto_pool, None) if ip is None: @@ -125,9 +146,8 @@ def _build_deckies_from_ini( raise typer.Exit(1) svc_list = spec.services elif randomize: - import random as _random - count = _random.randint(1, min(3, len(ALL_SERVICE_NAMES))) - svc_list = _random.sample(ALL_SERVICE_NAMES, count) + count = random.randint(1, min(3, len(ALL_SERVICE_NAMES))) + svc_list = random.sample(ALL_SERVICE_NAMES, count) else: console.print( f"[red]Decky '[{spec.name}]' has no services= in config. " @@ -139,7 +159,8 @@ def _build_deckies_from_ini( name=spec.name, ip=ip, services=svc_list, - base_image=base_image, + distro=distro.slug, + base_image=distro.image, hostname=hostname, )) return deckies @@ -154,6 +175,8 @@ def deploy( 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"), 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)"), 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"), @@ -223,8 +246,20 @@ def deploy( console.print(f"[dim]Interface:[/] {iface} [dim]Subnet:[/] {subnet_cidr} " 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) - 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 config = DecnetConfig( @@ -278,3 +313,15 @@ def list_services() -> None: for name, svc in sorted(svcs.items()): table.add_row(name, ", ".join(str(p) for p in svc.ports), svc.default_image) 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) diff --git a/decnet/composer.py b/decnet/composer.py index 59023bc..641c7e0 100644 --- a/decnet/composer.py +++ b/decnet/composer.py @@ -18,9 +18,6 @@ from decnet.services.registry import get_service _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: """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: dict = { - "image": _BASE_IMAGE, + "image": decky.base_image, "container_name": base_key, "hostname": decky.hostname, "command": ["sleep", "infinity"], diff --git a/decnet/config.py b/decnet/config.py index 9e5df70..bb6020a 100644 --- a/decnet/config.py +++ b/decnet/config.py @@ -4,35 +4,26 @@ State is persisted to decnet-state.json in the working directory. """ import json -import random from pathlib import Path from typing import Literal from pydantic import BaseModel, field_validator +from decnet.distros import random_hostname as _random_hostname + STATE_FILE = Path("decnet-state.json") -BASE_IMAGES = [ - "debian:bookworm-slim", - "ubuntu:22.04", -] -DECKY_NAME_WORDS = [ - "alpha", "bravo", "charlie", "delta", "echo", - "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)}" +def random_hostname(distro_slug: str = "debian") -> str: + return _random_hostname(distro_slug) class DeckyConfig(BaseModel): name: str ip: 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 @field_validator("services") diff --git a/decnet/distros.py b/decnet/distros.py new file mode 100644 index 0000000..e0e8265 --- /dev/null +++ b/decnet/distros.py @@ -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)