169 lines
6.4 KiB
Python
169 lines
6.4 KiB
Python
#!/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:]]))
|