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