#!/usr/bin/env python3 from __future__ import annotations from pathlib import Path from typing import Optional import yaml import re import typer from rich.console import Console app = typer.Typer( help="Generate Docker Compose and Traefik override files for a service.", add_completion=False, ) console = Console() INTRA_NETWORK = "intra" PROXY_NETWORK = "proxy" LAN_ONLY_MIDDLEWARE= "local-lan@file" PLACEHOLDER_IMAGE_NAME="IMAGE_NAME" def build_base_compose(service_name: str, image_name:str = PLACEHOLDER_IMAGE_NAME, include_intra_network: bool = False) -> dict: services = { service_name: { "image": image_name, } } networks = {} if include_intra_network: services["networks"] = [INTRA_NETWORK] networks[INTRA_NETWORK] = {"internal": True} return { "services": services, "networks": networks } if networks else { "services": services } def build_traefik_compose( service_name: str, fqdn: str, port: int, entrypoints: str = "https", middlewares: str = "" ) -> dict: labels = { "traefik.enable": "true", f"traefik.http.routers.{service_name}.entrypoints": "https", f"traefik.http.routers.{service_name}.rule": f"Host(`{fqdn}`)", f"traefik.http.routers.{service_name}.tls.certresolver": "letsencrypt", f"traefik.http.services.{service_name}.loadbalancer.server.port": str(port), } if middlewares: labels[f"traefik.http.routers.{service_name}.middlewares"] = middlewares return { "version": "3.8", "services": { service_name: { "labels": labels, "networks": [PROXY_NETWORK], } }, "networks": { PROXY_NETWORK: { "external": True, } }, } class IndentDumper(yaml.SafeDumper): # Force sequence indentation under mapping keys def increase_indent(self, flow=False, indentless=False): return super().increase_indent(flow, indentless=False) def derive_service_name(image: str) -> str: """ Derive a compose-safe service name from a Docker image reference. Examples: ghcr.io/org/whoami:latest -> whoami postgres:16 -> postgres library/redis -> redis my-registry:5000/app/api -> api """ # Strip digest, then tag no_digest = image.split("@", 1)[0] no_tag = no_digest.rsplit(":", 1)[0] if ":" in no_digest and "/" not in no_digest.split(":")[-1] else no_digest # Take last path segment last = no_tag.rsplit("/", 1)[-1] # Compose service names are fairly permissive, but keep it predictable: # - lowercase # - replace invalid chars with "-" # - collapse repeats name = last.lower() name = re.sub(r"[^a-z0-9._-]+", "-", name) name = re.sub(r"-{2,}", "-", name).strip("-") return name or "service" def dump_yaml(data: dict) -> str: return yaml.dump( data, Dumper = IndentDumper, sort_keys=False, default_flow_style=False, indent=2 ) def create_file(path: Path, content: str) -> None: if path.exists(): overwrite = typer.confirm( f"{path} already exists. Overwrite?", default=False, ) if not overwrite: console.print(f"[yellow]Skipping existing file:[/yellow] {path}") return path.write_text(content, encoding="utf-8") console.print(f"[green]Created:[/green] {path}") @app.command() def generate( image_path:str = typer.Option ( None, "--image", "-i", prompt_required=True, help="Image for the new service (e.g. 'nginx:latest')" ), base_domain: Optional[str] = typer.Option( None, "--base-domain", "-b", prompt=True, envvar="PRIMARY_DOMAIN", help="Base domain.", ), sub_name: Optional[str] = typer.Option( None, "--sub", "-u", help="Subdomain prefix. Defaults to service name.", ), port: int = typer.Option( 3000, "--port", "-p", prompt=True, help="Internal container port to route through Traefik.", ), service_name: str = typer.Option( None, "--service-name", "-s", help="Name of the service (e.g. 'whoami').", ), compose_file: Path = typer.Option( Path("compose.yml"), "--compose-file", "-c", help="Base Docker Compose file to create.", ), traefik_file: Path = typer.Option( Path("compose.traefik.yml"), "--traefik-file", "-t", help="Traefik override compose file to create.", ), intra: bool = typer.Option( False, "--intra/--no-intra", help="Attach the service to the internal 'intra' network.", ), lan_only: bool = typer.Option( False, "--lan-only/--no-lan-only", help="Attach the LAN-only middleware to the Traefik router.", ), ) -> None: image_value = image_path or PLACEHOLDER_IMAGE_NAME # Service name selection rules if service_name: final_service_name = service_name elif image_path: final_service_name = derive_service_name(image_path) else: final_service_name = typer.prompt("Service name") # Determine subdomain prefix if sub_name is None: sub_name = typer.prompt( "Subdomain prefix", default=final_service_name, show_default=True, ) fqdn = f"{sub_name}.{base_domain}" base = build_base_compose( final_service_name, image_name = image_value, include_intra_network = intra ) traefik = build_traefik_compose( final_service_name, fqdn, port, middlewares = LAN_ONLY_MIDDLEWARE if lan_only else "", ) create_file(compose_file, dump_yaml(base)) create_file(traefik_file, dump_yaml(traefik)) console.print() console.print("[bold green]Done.[/bold green]") console.print( "Start your stack:\n" f" docker compose -f {compose_file} -f {traefik_file} up -d" ) if __name__ == "__main__": app()