first commit
This commit is contained in:
234
mkcompose.py
Executable file
234
mkcompose.py
Executable 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()
|
||||
Reference in New Issue
Block a user