"""Image references, compose project names and published ports. Small parsers with large consequences: * ``docker.io/library/postgres`` is how many compose files name an image, and https://docker.io/v2/ is not a registry — so every such image sat at "could not reach registry" forever and an update never showed up. * compose refuses ``-p`` with anything but ``[a-z0-9_-]``, so a stack folder found on disk as ``Nextcloud`` could not be started, stopped or deleted. * the host's listening ports live in the host's network namespace, which ``/proc/net`` read from inside the container is not. """ from __future__ import annotations import pytest @pytest.mark.parametrize( "image, expected", [ ("nginx", ("registry-1.docker.io", "library/nginx", "latest")), ("docker.io/nginx:1", ("registry-1.docker.io", "library/nginx", "1")), ("docker.io/library/postgres:16", ("registry-1.docker.io", "library/postgres", "16")), ("index.docker.io/foo/bar:2", ("registry-1.docker.io", "foo/bar", "2")), ("ghcr.io/a/b:1@sha256:abc", ("ghcr.io", "a/b", "sha256:abc")), ("localhost:5000/x", ("localhost:5000", "x", "latest")), ("registry.example:5000/a/b:tag", ("registry.example:5000", "a/b", "tag")), ], ) def test_parse_ref(image, expected): from services.update_service import parse_ref assert parse_ref(image) == expected def test_the_local_digest_is_the_one_for_this_repository(monkeypatch): """An image pulled under two names carries a digest per repository.""" from services import update_service class Image: attrs = {"RepoDigests": [ "lscr.io/linuxserver/radarr@sha256:aaa", "linuxserver/radarr@sha256:bbb", ]} class Images: def get(self, name): return Image() class Client: images = Images() monkeypatch.setattr(update_service, "get_client", lambda: Client()) assert update_service._local_digest("linuxserver/radarr:latest") == "sha256:bbb" assert update_service._local_digest("lscr.io/linuxserver/radarr:latest") == "sha256:aaa" assert update_service._local_digest("docker.io/linuxserver/radarr") == "sha256:bbb" @pytest.mark.parametrize( "stack_id, project", [ ("immich", "immich"), ("arr_stack-2", "arr_stack-2"), ("Nextcloud", "nextcloud"), ("my.app", "myapp"), ("_hidden-ish", "hidden-ish"), ("...", "stack"), ], ) def test_project_name_matches_compose(stack_id, project): from services.compose_service import project_name assert project_name(stack_id) == project def test_compose_is_called_with_the_normalised_project(): from services import compose_service cmd = compose_service._compose_base_cmd("Nextcloud") assert cmd[cmd.index("-p") + 1] == "nextcloud" def test_published_ports_including_ranges_and_long_syntax(): from services.port_service import parse_compose_ports ports = parse_compose_ports( "services:\n a:\n ports:\n" ' - "8080:80"\n' ' - "127.0.0.1:9000:9000/udp"\n' ' - "7000-7002:7000-7002"\n' ' - "80"\n' ' - {target: 80, published: "8443"}\n' ' - "[::1]:5353:53/udp"\n' ) assert [(p["host_port"], p["protocol"]) for p in ports] == [ (8080, "tcp"), (9000, "udp"), (7000, "tcp"), (7001, "tcp"), (7002, "tcp"), (8443, "tcp"), (5353, "udp"), ] def test_garbage_compose_yields_no_ports(): from services.port_service import parse_compose_ports assert parse_compose_ports("just a string") == [] assert parse_compose_ports("services: [1, 2]") == [] assert parse_compose_ports(": : :") == [] def test_host_ports_are_read_from_the_hosts_namespace(tmp_path, monkeypatch): """/proc/net is self/net; PID 1 of the host /proc is the host's namespace.""" from config import settings from services import port_service (tmp_path / "net").mkdir() (tmp_path / "1" / "net").mkdir(parents=True) header = " sl local_address rem_address st\n" # The container's own table: just its API port. (tmp_path / "net" / "tcp").write_text(header + " 0: 00000000:1390 00000000:0000 0A\n") # The host's: ssh on 22 (0x16). (tmp_path / "1" / "net" / "tcp").write_text(header + " 0: 00000000:0016 00000000:0000 0A\n") monkeypatch.setattr(settings, "HOST_PROC_PATH", str(tmp_path)) assert port_service._read_proc_net("tcp") == {22}