"""Converting `docker run` into compose. The output has to be a compose file compose accepts and that means what the command meant. Before 0.67.1 a boolean flag the converter did not know swallowed the image name (``-dit ubuntu bash`` became a service running ``bash``), ``--network host`` and ``-v data:/data`` produced files compose rejects, and ``--gpus`` or ``--mount`` were dropped without a word. """ from __future__ import annotations import pytest import yaml def _service(command: str) -> tuple[str, dict, dict]: from services.convert_service import convert_docker_run doc = yaml.safe_load(convert_docker_run(command)) ((name, service),) = doc["services"].items() return name, service, doc @pytest.mark.parametrize( "command, image", [ ("docker run -dit ubuntu bash", "ubuntu"), ("docker run -d --read-only nginx", "nginx"), ("docker run -P nginx", "nginx"), ("docker run --rm --init --privileged alpine", "alpine"), ("docker run -d --oom-kill-disable --no-healthcheck redis:7", "redis:7"), ], ) def test_boolean_flags_never_swallow_the_image(command, image): _name, service, _doc = _service(command) assert service["image"] == image def test_the_command_after_the_image_is_kept(): name, service, _doc = _service("docker run -it --rm ubuntu bash -lc 'echo hi'") assert name == "ubuntu" assert service["command"] == ["bash", "-lc", "echo hi"] assert service["stdin_open"] is True and service["tty"] is True @pytest.mark.parametrize("mode", ["host", "none", "bridge", "container:vpn"]) def test_special_networks_become_network_mode(mode): _name, service, doc = _service(f"docker run -d --network {mode} alpine") assert service["network_mode"] == mode assert "networks" not in service and "networks" not in doc def test_a_named_network_is_declared_external(): _name, service, doc = _service("docker run -d --net=proxy --network-alias web alpine") assert service["networks"] == {"proxy": {"aliases": ["web"]}} assert doc["networks"] == {"proxy": {"external": True}} def test_named_volumes_are_declared_and_paths_are_not(): _name, service, doc = _service( "docker run -d -v data:/data -v /srv/media:/media:ro -v ./conf:/conf alpine" ) assert service["volumes"] == ["data:/data", "/srv/media:/media:ro", "./conf:/conf"] assert doc["volumes"] == {"data": None} def test_mount_becomes_long_syntax(): _name, service, doc = _service( "docker run --mount type=bind,source=/srv,target=/srv,readonly " "--mount type=volume,src=cache,dst=/cache alpine" ) assert service["volumes"][0] == { "type": "bind", "source": "/srv", "target": "/srv", "read_only": True, } assert service["volumes"][1] == {"type": "volume", "source": "cache", "target": "/cache"} assert doc["volumes"] == {"cache": None} @pytest.mark.parametrize( "value, expected", [ ("all", {"count": "all"}), ("2", {"count": 2}), ('"device=0,1"', {"device_ids": ["0", "1"]}), ], ) def test_gpus_become_a_device_reservation(value, expected): _name, service, _doc = _service(f"docker run -d --gpus {value} ollama/ollama") (device,) = service["deploy"]["resources"]["reservations"]["devices"] assert device["driver"] == "nvidia" assert device["capabilities"] == ["gpu"] for key, val in expected.items(): assert device[key] == val def test_attached_short_values_and_equals_forms(): _name, service, _doc = _service( "docker run -p8080:80 -eTZ=Europe/Berlin --restart=always --name=web nginx" ) assert service["ports"] == ["8080:80"] assert service["environment"] == ["TZ=Europe/Berlin"] assert service["restart"] == "always" assert service["container_name"] == "web" def test_common_runtime_flags_are_kept(): _name, service, _doc = _service( "sudo docker run -d --name plex --entrypoint /init --shm-size 1g -m 2g --cpus 1.5 " "--cap-add NET_ADMIN --cap-drop ALL --security-opt no-new-privileges " "--sysctl net.ipv4.ip_forward=1 --add-host nas:10.0.0.2 --dns 1.1.1.1 " "--ulimit nofile=1024:2048 --log-opt max-size=10m --tmpfs /run " "--health-cmd 'curl -f http://localhost' --health-interval 30s --stop-timeout 30 " "lscr.io/linuxserver/plex:latest" ) assert service["entrypoint"] == "/init" assert service["shm_size"] == "1g" assert service["mem_limit"] == "2g" assert service["cpus"] == 1.5 assert service["cap_add"] == ["NET_ADMIN"] and service["cap_drop"] == ["ALL"] assert service["security_opt"] == ["no-new-privileges"] assert service["sysctls"] == ["net.ipv4.ip_forward=1"] assert service["extra_hosts"] == ["nas:10.0.0.2"] assert service["dns"] == ["1.1.1.1"] assert service["ulimits"] == {"nofile": {"soft": 1024, "hard": 2048}} assert service["logging"] == {"options": {"max-size": "10m"}} assert service["tmpfs"] == ["/run"] assert service["healthcheck"] == { "test": ["CMD-SHELL", "curl -f http://localhost"], "interval": "30s", } assert service["stop_grace_period"] == "30s" def test_line_continuations_from_a_readme(): name, service, _doc = _service("docker run -d \\\n --name x \\\r\n -p 1:1 \\\n alpine") assert name == "x" assert service["ports"] == ["1:1"] def test_the_service_name_comes_from_the_image_without_registry_or_tag(): name, _service_, _doc = _service("docker run -d ghcr.io/home-assistant/home-assistant:stable") assert name == "home-assistant" def test_no_image_is_an_error(): from services.convert_service import convert_docker_run with pytest.raises(ValueError): convert_docker_run("docker run -d --name x") @pytest.mark.parametrize( "command", [ # Pasted into a one-line field: the newlines are gone, the backslashes # and indentation are not. "docker run -d \\ --name web \\ -p 8081:80 \\ -v data:/data \\ nginx:alpine", "docker run -d \\ --name web \\ -p 8081:80 \\\n nginx:alpine", ], ) def test_continuations_that_lost_their_newline(command): name, web, _ = _service(command) assert name == "web" and web["image"] == "nginx:alpine" assert web["ports"] == ["8081:80"] def test_an_escaped_space_in_a_path_is_still_one(): _, service, _ = _service("docker run -v /mnt/My\\ Files:/data nginx") assert service["volumes"] == ["/mnt/My Files:/data"]