first commit

This commit is contained in:
2025-12-29 13:28:50 -05:00
commit d3116fddef

234
mkcompose.py Executable file
View File

@@ -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()