feat: Add script to generate Caddy config from Docker container labels
This commit is contained in:
1 parent
9e2659d3f1
commit
aadb47a955
2 files changed
+413
No files matched your search
Executable
+245
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# dependencies = []
|
||||||
|
# ///
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class HelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate Caddy reverse proxies from Docker container labels.",
|
||||||
|
formatter_class=HelpFormatter,
|
||||||
|
epilog="""Compose service labels:
|
||||||
|
labels:
|
||||||
|
caddy.domain: ascii.app.abdus.dev
|
||||||
|
caddy.port: "3000"
|
||||||
|
caddy.basic_auth: "true"
|
||||||
|
|
||||||
|
caddy.port selects the internal TCP port, not the published host port.
|
||||||
|
Omit it when exactly one TCP port is published. Only running labeled
|
||||||
|
containers generate routes. Potential Docker port clashes warn on stderr.
|
||||||
|
caddy.basic_auth accepts true or false (default). When true, the route
|
||||||
|
imports the (auth) snippet defined in your handwritten Caddy config.
|
||||||
|
|
||||||
|
Local usage:
|
||||||
|
python3 docker_to_caddy.py
|
||||||
|
python3 docker_to_caddy.py --output docker.generated.caddy
|
||||||
|
|
||||||
|
Remote Docker over SSH:
|
||||||
|
env -u DOCKER_CONTEXT DOCKER_HOST=ssh://abdus@your-server docker ps
|
||||||
|
env -u DOCKER_CONTEXT DOCKER_HOST=ssh://abdus@your-server \\
|
||||||
|
python3 docker_to_caddy.py --output docker.generated.caddy
|
||||||
|
|
||||||
|
The SSH user needs Docker socket access on the server. These commands do
|
||||||
|
not change your selected Docker context (for example, OrbStack). Output is
|
||||||
|
written locally; loopback upstreams refer to the server where Caddy runs.
|
||||||
|
|
||||||
|
Caddy setup:
|
||||||
|
Add a top-level import docker.generated.caddy to your handwritten config.
|
||||||
|
Remove migrated proxy blocks, then validate and reload Caddy manually.
|
||||||
|
The script does not configure DNS or reload Caddy.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", type=Path, help="Output file; otherwise write to stdout")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def docker_json(arguments: list[str]) -> list[Any]:
|
||||||
|
result = subprocess.run(
|
||||||
|
args=["docker", *arguments], check=True, capture_output=True, text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
value = json.loads(result.stdout)
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ValueError("Docker returned an unexpected response")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_containers() -> list[dict[str, Any]]:
|
||||||
|
result = subprocess.run(
|
||||||
|
args=["docker", "container", "ls", "--all", "--quiet"],
|
||||||
|
check=True, capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
identifiers = result.stdout.split()
|
||||||
|
containers = []
|
||||||
|
for offset in range(0, len(identifiers), 100):
|
||||||
|
containers.extend(docker_json(arguments=[
|
||||||
|
"container", "inspect", *identifiers[offset:offset + 100],
|
||||||
|
]))
|
||||||
|
return containers
|
||||||
|
|
||||||
|
|
||||||
|
def container_name(container: dict[str, Any]) -> str:
|
||||||
|
return str(container.get("Name") or container["Id"]).lstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def port_number(value: str) -> int:
|
||||||
|
if not re.fullmatch(r"[0-9]+", value) or not 1 <= int(value) <= 65535:
|
||||||
|
raise ValueError(f"Invalid port: {value!r}")
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_domain(value: str) -> str:
|
||||||
|
domain = value.lower().rstrip(".")
|
||||||
|
labels = domain.split(".")
|
||||||
|
if len(domain) > 253 or len(labels) < 2 or any(
|
||||||
|
not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
|
||||||
|
for label in labels
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid domain: {value!r}; use a single DNS hostname")
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
def upstream(binding: dict[str, str]) -> tuple[int, str, int]:
|
||||||
|
address = ipaddress.ip_address(binding.get("HostIp") or "0.0.0.0")
|
||||||
|
if address.is_unspecified:
|
||||||
|
address = ipaddress.ip_address("127.0.0.1" if address.version == 4 else "::1")
|
||||||
|
port = port_number(value=binding["HostPort"])
|
||||||
|
return address.version, str(address), port
|
||||||
|
|
||||||
|
|
||||||
|
def render_config(containers: list[dict[str, Any]]) -> str:
|
||||||
|
routes: dict[str, tuple[str, str, bool]] = {}
|
||||||
|
for container in containers:
|
||||||
|
labels = container.get("Config", {}).get("Labels") or {}
|
||||||
|
if "caddy.domain" not in labels or not container.get("State", {}).get("Running"):
|
||||||
|
continue
|
||||||
|
name = container_name(container=container)
|
||||||
|
try:
|
||||||
|
domain = normalize_domain(value=labels["caddy.domain"])
|
||||||
|
auth_label = labels.get("caddy.basic_auth", "false")
|
||||||
|
if not isinstance(auth_label, str) or auth_label.lower() not in ("true", "false"):
|
||||||
|
raise ValueError("caddy.basic_auth must be true or false")
|
||||||
|
basic_auth = auth_label.lower() == "true"
|
||||||
|
if container.get("HostConfig", {}).get("NetworkMode") == "host":
|
||||||
|
raise ValueError("Host networking is unsupported; publish a TCP port instead")
|
||||||
|
ports = {
|
||||||
|
key: [binding for binding in bindings or [] if binding.get("HostPort")]
|
||||||
|
for key, bindings in (container.get("NetworkSettings", {}).get("Ports") or {}).items()
|
||||||
|
if key.endswith("/tcp")
|
||||||
|
}
|
||||||
|
ports = {key: bindings for key, bindings in ports.items() if bindings}
|
||||||
|
if "caddy.port" in labels:
|
||||||
|
key = f"{port_number(value=labels['caddy.port'])}/tcp"
|
||||||
|
if key not in ports:
|
||||||
|
raise ValueError(f"Selected container port {key} has no published host port")
|
||||||
|
elif len(ports) == 1:
|
||||||
|
key = next(iter(ports))
|
||||||
|
else:
|
||||||
|
raise ValueError("Expected one published TCP port; set caddy.port to select one")
|
||||||
|
version, address, port = min(upstream(binding=binding) for binding in ports[key])
|
||||||
|
target = f"[{address}]:{port}" if version == 6 else f"{address}:{port}"
|
||||||
|
if domain in routes:
|
||||||
|
raise ValueError(f"Domain {domain} is also claimed by {routes[domain][0]}")
|
||||||
|
routes[domain] = (name, target, basic_auth)
|
||||||
|
except (ValueError, KeyError, TypeError) as error:
|
||||||
|
raise ValueError(f"Container {name}: {error}") from error
|
||||||
|
blocks = ["# Generated by docker_to_caddy.py; do not edit.\n"]
|
||||||
|
for domain, (name, target, basic_auth) in sorted(routes.items()):
|
||||||
|
safe_name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
|
||||||
|
auth_import = " import auth\n" if basic_auth else ""
|
||||||
|
blocks.append(
|
||||||
|
f"# Container: {safe_name}\n{domain} {{\n{auth_import} reverse_proxy {target}\n}}\n"
|
||||||
|
)
|
||||||
|
return "\n".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def addresses_overlap(first: str, second: str) -> bool:
|
||||||
|
left = ipaddress.ip_address(first or "0.0.0.0")
|
||||||
|
right = ipaddress.ip_address(second or "0.0.0.0")
|
||||||
|
if left.version != right.version:
|
||||||
|
return (left.version == 6 and left.is_unspecified) or (
|
||||||
|
right.version == 6 and right.is_unspecified
|
||||||
|
)
|
||||||
|
return left == right or left.is_unspecified or right.is_unspecified
|
||||||
|
|
||||||
|
|
||||||
|
def warn_port_clashes(containers: list[dict[str, Any]]) -> None:
|
||||||
|
bindings: list[tuple[str, str, str, int, str]] = []
|
||||||
|
for container in containers:
|
||||||
|
name = container_name(container=container)
|
||||||
|
state = container.get("State", {}).get("Status", "unknown")
|
||||||
|
configured = container.get("HostConfig", {}).get("PortBindings") or {}
|
||||||
|
actual = container.get("NetworkSettings", {}).get("Ports") or {}
|
||||||
|
seen: set[tuple[str, int, str]] = set()
|
||||||
|
for source in (configured, actual):
|
||||||
|
for container_port, entries in source.items():
|
||||||
|
protocol = container_port.rsplit("/", 1)[-1]
|
||||||
|
for binding in entries or []:
|
||||||
|
value = binding.get("HostPort")
|
||||||
|
if not value or value == "0":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
port = port_number(value=value)
|
||||||
|
address = str(ipaddress.ip_address(binding.get("HostIp") or "0.0.0.0"))
|
||||||
|
except ValueError as error:
|
||||||
|
logging.warning("Cannot check binding for %s: %s", name, error)
|
||||||
|
continue
|
||||||
|
identity = (address, port, protocol)
|
||||||
|
if identity not in seen:
|
||||||
|
seen.add(identity)
|
||||||
|
bindings.append((name, state, address, port, protocol))
|
||||||
|
warned: set[tuple[str, str, int, str]] = set()
|
||||||
|
for index, first in enumerate(bindings):
|
||||||
|
for second in bindings[index + 1:]:
|
||||||
|
if first[0] == second[0] or first[3:] != second[3:]:
|
||||||
|
continue
|
||||||
|
if not addresses_overlap(first=first[2], second=second[2]):
|
||||||
|
continue
|
||||||
|
identity = (*sorted((first[0], second[0])), first[3], first[4])
|
||||||
|
if identity in warned:
|
||||||
|
continue
|
||||||
|
warned.add(identity)
|
||||||
|
logging.warning(
|
||||||
|
"Potential port clash: %s (%s) [%s]:%s/%s and %s (%s) [%s]:%s/%s",
|
||||||
|
*first, *second,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config(config: str, output: Path) -> None:
|
||||||
|
output = output.absolute()
|
||||||
|
with tempfile.TemporaryDirectory(dir=output.parent, prefix=".docker-to-caddy-") as directory:
|
||||||
|
temporary = Path(directory) / "config"
|
||||||
|
temporary.write_text(data=config, encoding="utf-8")
|
||||||
|
if output.exists():
|
||||||
|
temporary.chmod(mode=output.stat().st_mode & 0o777)
|
||||||
|
os.replace(src=temporary, dst=output)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||||
|
arguments = parse_args()
|
||||||
|
try:
|
||||||
|
containers = inspect_containers()
|
||||||
|
warn_port_clashes(containers=containers)
|
||||||
|
config = render_config(containers=containers)
|
||||||
|
if arguments.output is None:
|
||||||
|
sys.stdout.write(config)
|
||||||
|
else:
|
||||||
|
write_config(config=config, output=arguments.output)
|
||||||
|
logging.info("Wrote %s", arguments.output)
|
||||||
|
return 0
|
||||||
|
except subprocess.CalledProcessError as error:
|
||||||
|
logging.error("Docker command failed: %s", (error.stderr or str(error)).strip())
|
||||||
|
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
||||||
|
logging.error("%s", error)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# dependencies = ["pytest"]
|
||||||
|
# ///
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import docker_to_caddy as script
|
||||||
|
|
||||||
|
|
||||||
|
def container(name: str = "app", domain: str = "ascii.app.abdus.dev") -> dict:
|
||||||
|
return {
|
||||||
|
"Name": f"/{name}",
|
||||||
|
"Config": {"Labels": {"caddy.domain": domain}},
|
||||||
|
"State": {"Running": True, "Status": "running"},
|
||||||
|
"HostConfig": {"PortBindings": {
|
||||||
|
"3000/tcp": [{"HostIp": "127.0.0.1", "HostPort": "4510"}],
|
||||||
|
}},
|
||||||
|
"NetworkSettings": {"Ports": {
|
||||||
|
"3000/tcp": [{"HostIp": "127.0.0.1", "HostPort": "4510"}],
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("address, expected", [
|
||||||
|
("127.0.0.1", "127.0.0.1:4510"),
|
||||||
|
("0.0.0.0", "127.0.0.1:4510"),
|
||||||
|
("::", "[::1]:4510"),
|
||||||
|
("::1", "[::1]:4510"),
|
||||||
|
("192.168.1.2", "192.168.1.2:4510"),
|
||||||
|
])
|
||||||
|
def test_bindings(address: str, expected: str) -> None:
|
||||||
|
app = container()
|
||||||
|
app["NetworkSettings"]["Ports"]["3000/tcp"][0]["HostIp"] = address
|
||||||
|
assert f"reverse_proxy {expected}" in script.render_config(containers=[app])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("selector, succeeds", [(None, False), ("3000", True), ("9000", False)])
|
||||||
|
def test_port_selection(selector: str | None, succeeds: bool) -> None:
|
||||||
|
app = container()
|
||||||
|
app["NetworkSettings"]["Ports"]["4000/tcp"] = [
|
||||||
|
{"HostIp": "::1", "HostPort": "4600"},
|
||||||
|
]
|
||||||
|
if selector is not None:
|
||||||
|
app["Config"]["Labels"]["caddy.port"] = selector
|
||||||
|
if succeeds:
|
||||||
|
assert "127.0.0.1:4510" in script.render_config(containers=[app])
|
||||||
|
else:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
script.render_config(containers=[app])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("domain", ["", "bad host.dev", "example.dev {", "*.abdus.dev", "https://abdus.dev"])
|
||||||
|
def test_invalid_domains(domain: str) -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
script.render_config(containers=[container(domain=domain)])
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_domains() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
script.render_config(containers=[container(), container(name="other")])
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_published_ports() -> None:
|
||||||
|
app = container()
|
||||||
|
app["NetworkSettings"]["Ports"] = {}
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
script.render_config(containers=[app])
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_networking() -> None:
|
||||||
|
app = container()
|
||||||
|
app["HostConfig"]["NetworkMode"] = "host"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
script.render_config(containers=[app])
|
||||||
|
|
||||||
|
|
||||||
|
def test_stopped_unlabeled_and_empty() -> None:
|
||||||
|
stopped = container()
|
||||||
|
stopped["State"] = {"Running": False, "Status": "exited"}
|
||||||
|
unlabeled = container()
|
||||||
|
unlabeled["Config"]["Labels"] = None
|
||||||
|
assert script.render_config(containers=[stopped, unlabeled]) == script.render_config(containers=[])
|
||||||
|
|
||||||
|
|
||||||
|
def test_deterministic_and_ipv4_preference() -> None:
|
||||||
|
first = container(name="a", domain="a.abdus.dev")
|
||||||
|
second = container(name="z", domain="z.abdus.dev")
|
||||||
|
first["NetworkSettings"]["Ports"]["3000/tcp"].append(
|
||||||
|
{"HostIp": "::", "HostPort": "4510"},
|
||||||
|
)
|
||||||
|
forward = script.render_config(containers=[first, second])
|
||||||
|
assert forward == script.render_config(containers=[second, first])
|
||||||
|
assert "reverse_proxy 127.0.0.1:4510" in forward
|
||||||
|
assert forward.index("a.abdus.dev") < forward.index("z.abdus.dev")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("left, right, protocol, port, expected", [
|
||||||
|
("0.0.0.0", "127.0.0.1", "tcp", "4510", True),
|
||||||
|
("127.0.0.1", "127.0.0.2", "tcp", "4510", False),
|
||||||
|
("::", "127.0.0.1", "tcp", "4510", True),
|
||||||
|
("127.0.0.1", "127.0.0.1", "udp", "4510", False),
|
||||||
|
("127.0.0.1", "127.0.0.1", "tcp", "0", False),
|
||||||
|
])
|
||||||
|
def test_clashes(left: str, right: str, protocol: str, port: str, expected: bool) -> None:
|
||||||
|
first = container()
|
||||||
|
first["HostConfig"]["PortBindings"]["3000/tcp"][0]["HostIp"] = left
|
||||||
|
first["NetworkSettings"]["Ports"] = copy.deepcopy(first["HostConfig"]["PortBindings"])
|
||||||
|
second = container(name="stopped")
|
||||||
|
second["Config"]["Labels"] = None
|
||||||
|
second["State"] = {"Running": False, "Status": "exited"}
|
||||||
|
second["NetworkSettings"]["Ports"] = {}
|
||||||
|
second["HostConfig"]["PortBindings"] = {
|
||||||
|
f"3000/{protocol}": [{"HostIp": right, "HostPort": port}],
|
||||||
|
}
|
||||||
|
with patch.object(script.logging, "warning") as warning:
|
||||||
|
script.warn_port_clashes(containers=[first, second])
|
||||||
|
assert warning.call_count == int(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_and_failure_preservation(tmp_path: Path) -> None:
|
||||||
|
output = tmp_path / "generated.caddy"
|
||||||
|
output.write_text(data="previous", encoding="utf-8")
|
||||||
|
output.chmod(mode=0o640)
|
||||||
|
with patch("sys.argv", ["docker_to_caddy.py", "--output", str(output)]):
|
||||||
|
with patch.object(script, "inspect_containers", return_value=[container(), container()]):
|
||||||
|
assert script.main() == 1
|
||||||
|
assert output.read_text() == "previous"
|
||||||
|
with patch.object(script, "inspect_containers", side_effect=subprocess.CalledProcessError(
|
||||||
|
returncode=1, cmd="docker", stderr="permission denied",
|
||||||
|
)):
|
||||||
|
assert script.main() == 1
|
||||||
|
assert output.read_text() == "previous"
|
||||||
|
script.write_config(config="replacement", output=output)
|
||||||
|
assert output.read_text() == "replacement"
|
||||||
|
assert output.stat().st_mode & 0o777 == 0o640
|
||||||
|
|
||||||
|
|
||||||
|
def test_stdout(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with patch("sys.argv", ["docker_to_caddy.py"]):
|
||||||
|
with patch.object(script, "inspect_containers", return_value=[container()]):
|
||||||
|
assert script.main() == 0
|
||||||
|
assert capsys.readouterr().out == script.render_config(containers=[container()])
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_discovery() -> None:
|
||||||
|
responses = [
|
||||||
|
subprocess.CompletedProcess(args=[], returncode=0, stdout="id1\nid2\n"),
|
||||||
|
subprocess.CompletedProcess(args=[], returncode=0, stdout='[{"Name":"/one"},{"Name":"/two"}]'),
|
||||||
|
]
|
||||||
|
with patch.object(script.subprocess, "run", side_effect=responses) as run:
|
||||||
|
assert script.inspect_containers() == [{"Name": "/one"}, {"Name": "/two"}]
|
||||||
|
assert run.call_args_list[1].kwargs["args"] == ["docker", "container", "inspect", "id1", "id2"]
|
||||||
|
with patch.object(script.subprocess, "run", return_value=subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0, stdout="",
|
||||||
|
)) as run:
|
||||||
|
assert script.inspect_containers() == []
|
||||||
|
assert run.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(pytest.main(args=[str(Path(__file__).resolve()), *sys.argv[1:]]))
|
||||||
Reference in new issue
Block a user