commit d3116fddef12e535e67222e0f8e510fdfb922019 Author: Anthony Correa Date: Mon Dec 29 13:28:50 2025 -0500 first commit diff --git a/mkcompose.py b/mkcompose.py new file mode 100755 index 0000000..be37194 --- /dev/null +++ b/mkcompose.py @@ -0,0 +1,234 @@ +#!/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() \ No newline at end of file