Files
computer-playbook/tests/unit/python/utils/docker/test_registry.py
T
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).

This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.

Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:

  * 7 `src:` values built from a role variable holding a bare filename
    (svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
    The variable doubles as the destination name inside the container, so the
    language segment belongs at the call site, not in the variable.
  * `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
    through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
    18 roles ship exactly that and have no template fallback: the play would
    have died on each.
  * `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
    interface now reads `files/javascript/javascript.js`.
  * `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
    path through `path_absolute(... 'files', ...)`.
  * 4 `ansible.builtin.script:` invocations naming a bare command
    (pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
  * `dev-python/files/shell/install.sh` pointed at a sibling role's script.

Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:09 +02:00

283 lines
11 KiB
Python

from __future__ import annotations
import io
import os
import tempfile
import unittest
import unittest.mock as mock
import urllib.error
from email.message import Message
from pathlib import Path
from utils.docker import registry
class _IsolatedProbeCache(unittest.TestCase):
"""Give each test its own probe cache and no mirror."""
def setUp(self) -> None:
super().setUp()
cache = tempfile.TemporaryDirectory()
self.addCleanup(cache.cleanup)
for patcher in (
mock.patch.object(registry, "_CACHE_ROOT", Path(cache.name)),
mock.patch.dict(os.environ, {"INFINITO_GHCR_MIRROR_PREFIX": ""}),
):
patcher.start()
self.addCleanup(patcher.stop)
def _headers(values: dict[str, str] | None = None) -> Message:
msg = Message()
for key, value in (values or {}).items():
msg[key] = value
return msg
class _Resp:
def __init__(self, status: int = 200, headers=None, body: bytes = b""):
self.status = status
self.headers = _headers(headers)
self._body = body
def read(self) -> bytes:
return self._body
def __enter__(self):
return self
def __exit__(self, *_exc) -> bool:
return False
def _http_error(code: int, headers=None) -> urllib.error.HTTPError:
return urllib.error.HTTPError(
"https://reg/v2", code, "err", _headers(headers), io.BytesIO(b"")
)
class TestManifestExists(_IsolatedProbeCache):
def test_present_returns_true(self) -> None:
with mock.patch.object(
registry.urllib.request, "urlopen", return_value=_Resp(status=200)
) as opened:
self.assertIs(registry.manifest_exists("postgres", "16"), True)
url = opened.call_args.args[0].full_url
self.assertEqual(
url, "https://registry-1.docker.io/v2/library/postgres/manifests/16"
)
def test_absent_returns_false(self) -> None:
with mock.patch.object(
registry.urllib.request, "urlopen", side_effect=_http_error(404)
):
self.assertIs(registry.manifest_exists("postgres", "999"), False)
def test_network_error_is_indeterminate(self) -> None:
with mock.patch.object(
registry.urllib.request, "urlopen", side_effect=urllib.error.URLError("x")
):
self.assertIsNone(registry.manifest_exists("postgres", "16"))
def test_auth_wall_without_realm_is_indeterminate(self) -> None:
with mock.patch.object(
registry.urllib.request, "urlopen", side_effect=_http_error(401)
):
self.assertIsNone(registry.manifest_exists("quay.io/foo/bar", "1"))
def test_non_dockerhub_registry_host(self) -> None:
with mock.patch.object(
registry.urllib.request, "urlopen", return_value=_Resp(status=200)
) as opened:
registry.manifest_exists("quay.io/keycloak/keycloak", "26.6.3")
url = opened.call_args.args[0].full_url
self.assertEqual(url, "https://quay.io/v2/keycloak/keycloak/manifests/26.6.3")
class TestFetchRegistryTags(unittest.TestCase):
def test_anonymous_tags(self) -> None:
with mock.patch.object(
registry.urllib.request,
"urlopen",
return_value=_Resp(body=b'{"tags": ["1.0.0", "1.1.0"]}'),
):
self.assertEqual(
registry.fetch_registry_tags("mcr.microsoft.com/foo/bar"),
["1.0.0", "1.1.0"],
)
def test_bearer_challenge_then_retry(self) -> None:
challenge = 'Bearer realm="https://auth.example/token",service="reg",scope="repository:foo/bar:pull"'
def _fake(req, *_a, **_kw):
url = req.full_url
if url.startswith("https://auth.example/token"):
return _Resp(body=b'{"token": "abc"}')
if "Authorization" not in req.headers and "Authorization" not in {
k.title(): v for k, v in req.header_items()
}:
raise _http_error(401, {"WWW-Authenticate": challenge})
return _Resp(body=b'{"tags": ["2.0.0"]}')
with mock.patch.object(registry.urllib.request, "urlopen", side_effect=_fake):
self.assertEqual(registry.fetch_registry_tags("ghcr.io/foo/bar"), ["2.0.0"])
def test_pagination_follows_link(self) -> None:
page1 = _Resp(
headers={"Link": '</v2/library/foo/tags/list?n=100&last=b>; rel="next"'},
body=b'{"tags": ["a"]}',
)
page2 = _Resp(body=b'{"tags": ["b"]}')
with mock.patch.object(
registry.urllib.request, "urlopen", side_effect=[page1, page2]
):
self.assertEqual(registry.fetch_registry_tags("foo"), ["a", "b"])
def test_network_failure_returns_empty(self) -> None:
with mock.patch.object(
registry.urllib.request, "urlopen", side_effect=urllib.error.URLError("x")
):
self.assertEqual(registry.fetch_registry_tags("foo"), [])
def test_last_cursor_seeds_query(self) -> None:
with mock.patch.object(
registry.urllib.request,
"urlopen",
return_value=_Resp(body=b'{"tags": ["v1.0.0"]}'),
) as opened:
self.assertEqual(
registry.fetch_registry_tags("registry.gitlab.com/foo/bar", last="v"),
["v1.0.0"],
)
url = opened.call_args.args[0].full_url
self.assertEqual(
url, "https://registry.gitlab.com/v2/foo/bar/tags/list?n=1000&last=v"
)
_SINGLE = {
"config": {"size": 100},
"layers": [{"size": 900}, {"size": 1_000}],
}
_INDEX = {
"manifests": [
{"digest": "sha256:arm", "platform": {"os": "linux", "architecture": "arm64"}},
{"digest": "sha256:amd", "platform": {"os": "linux", "architecture": "amd64"}},
{
"digest": "sha256:att",
"platform": {"os": "unknown", "architecture": "unknown"},
},
]
}
class TestManifestTransferSize(_IsolatedProbeCache):
def test_single_manifest_sums_config_and_layers(self) -> None:
with mock.patch.object(registry, "fetch_manifest", return_value=_SINGLE):
self.assertEqual(registry.manifest_transfer_size("img", "1"), 2_000)
def test_index_resolves_the_requested_platform(self) -> None:
with mock.patch.object(
registry, "fetch_manifest", side_effect=[_INDEX, _SINGLE]
) as fetched:
self.assertEqual(registry.manifest_transfer_size("img", "1"), 2_000)
self.assertEqual(fetched.call_args_list[1].args, ("img", "sha256:amd"))
def test_index_without_the_platform_is_indeterminate(self) -> None:
with mock.patch.object(registry, "fetch_manifest", return_value=_INDEX):
self.assertIsNone(
registry.manifest_transfer_size("img", "1", architecture="riscv64")
)
def test_missing_config_size_counts_layers_only(self) -> None:
with mock.patch.object(
registry, "fetch_manifest", return_value={"layers": [{"size": 7}]}
):
self.assertEqual(registry.manifest_transfer_size("img", "1"), 7)
def test_layerless_manifest_is_indeterminate(self) -> None:
with mock.patch.object(registry, "fetch_manifest", return_value={"config": {}}):
self.assertIsNone(registry.manifest_transfer_size("img", "1"))
def test_a_measured_size_is_served_from_cache(self) -> None:
with mock.patch.object(registry, "fetch_manifest", return_value=_SINGLE):
self.assertEqual(registry.manifest_transfer_size("img", "1"), 2_000)
with mock.patch.object(registry, "fetch_manifest") as fetched:
self.assertEqual(registry.manifest_transfer_size("img", "1"), 2_000)
fetched.assert_not_called()
def test_an_indeterminate_answer_is_not_cached(self) -> None:
with mock.patch.object(registry, "fetch_manifest", return_value=None):
self.assertIsNone(registry.manifest_transfer_size("img", "1"))
with mock.patch.object(registry, "fetch_manifest", return_value=_SINGLE):
self.assertEqual(registry.manifest_transfer_size("img", "1"), 2_000)
def test_platform_is_part_of_the_cache_key(self) -> None:
with mock.patch.object(registry, "fetch_manifest", return_value=_SINGLE):
self.assertEqual(registry.manifest_transfer_size("img", "1"), 2_000)
with mock.patch.object(
registry, "fetch_manifest", return_value={"layers": [{"size": 9}]}
):
self.assertEqual(
registry.manifest_transfer_size("img", "1", architecture="arm64"), 9
)
class TestProbeOrder(_IsolatedProbeCache):
def test_the_mirror_is_probed_before_the_upstream_registry(self) -> None:
with mock.patch.dict(os.environ, {"INFINITO_GHCR_MIRROR_PREFIX": "mirror"}):
order = registry._probe_order("postgres")
self.assertEqual(len(order), 2)
self.assertTrue(order[0].startswith("ghcr.io/"))
self.assertTrue(order[0].endswith("/mirror/docker.io/postgres"))
self.assertEqual(order[1], "postgres")
def test_upstream_is_the_only_probe_without_a_mirror(self) -> None:
self.assertEqual(registry._probe_order("postgres"), ["postgres"])
def test_upstream_answers_when_the_mirror_lacks_the_image(self) -> None:
with (
mock.patch.dict(os.environ, {"INFINITO_GHCR_MIRROR_PREFIX": "mirror"}),
mock.patch.object(
registry, "fetch_manifest", side_effect=[None, _SINGLE]
) as fetched,
):
self.assertEqual(registry.manifest_transfer_size("postgres", "17"), 2_000)
self.assertEqual(fetched.call_args_list[1].args[0], "postgres")
def test_unreachable_manifest_is_indeterminate(self) -> None:
with mock.patch.object(registry, "fetch_manifest", return_value=None):
self.assertIsNone(registry.manifest_transfer_size("img", "1"))
def test_nested_index_is_indeterminate(self) -> None:
with mock.patch.object(
registry, "fetch_manifest", side_effect=[_INDEX, _INDEX]
):
self.assertIsNone(registry.manifest_transfer_size("img", "1"))
class TestFetchManifest(unittest.TestCase):
def test_non_dict_body_is_indeterminate(self) -> None:
with mock.patch.object(
registry.urllib.request, "urlopen", return_value=_Resp(body=b"[]")
):
self.assertIsNone(registry.fetch_manifest("img", "1"))
def test_rate_limited_is_indeterminate(self) -> None:
with mock.patch.object(
registry.urllib.request, "urlopen", side_effect=_http_error(429)
):
self.assertIsNone(registry.fetch_manifest("img", "1"))
def test_present_manifest_is_parsed(self) -> None:
with mock.patch.object(
registry.urllib.request,
"urlopen",
return_value=_Resp(body=b'{"layers": []}'),
):
self.assertEqual(registry.fetch_manifest("img", "1"), {"layers": []})
if __name__ == "__main__":
unittest.main()