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:
@@ -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)
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
119
decnet/distros.py
Normal 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)
|
||||||
Reference in New Issue
Block a user