from __future__ import annotations import unittest from collections.abc import Mapping import yaml from plugins.lookup.container_volumes import container_volumes def _apps(volumes_meta): return {"my-app": {"volumes": volumes_meta}} def _parse(out: str) -> dict: if not out: return {} parsed = yaml.safe_load(out) # nocheck: direct-yaml plugin output string return parsed or {} class TestContainerVolumesShortFormBind(unittest.TestCase): def test_canonical_bind_renders_short_form(self) -> None: meta = { "cfg": { "type": "bind", "source": "/etc/foo.conf", "mounts": [{"service": "app", "target": "/app/foo.conf"}], } } out = container_volumes(_apps(meta), "my-app", "app") data = _parse(out) self.assertEqual(data, {"volumes": ["/etc/foo.conf:/app/foo.conf"]}) def test_bind_read_only_emits_ro_suffix(self) -> None: meta = { "cfg": { "type": "bind", "source": "/etc/foo.conf", "read_only": True, "mounts": [{"service": "app", "target": "/app/foo.conf"}], } } out = container_volumes(_apps(meta), "my-app", "app") data = _parse(out) self.assertEqual(data, {"volumes": ["/etc/foo.conf:/app/foo.conf:ro"]}) def test_per_mount_read_only_overrides_volume_level(self) -> None: meta = { "cfg": { "type": "bind", "source": "/etc/foo.conf", "read_only": True, "mounts": [ {"service": "app", "target": "/app/foo.conf", "read_only": False} ], } } out = container_volumes(_apps(meta), "my-app", "app") data = _parse(out) self.assertEqual(data, {"volumes": ["/etc/foo.conf:/app/foo.conf"]}) class TestContainerVolumesNamed(unittest.TestCase): def test_canonical_volume_with_mount_uses_semantic_name(self) -> None: """Per-service mount references the SEMANTIC name (YAML key), so the top-level `volumes:` key from compose_volumes (also keyed by semantic name) resolves the mount. Using the docker name here would produce a compose project referencing an undefined top-level key and `docker compose up` would fail with 'undefined volume'.""" meta = { "data": { "type": "volume", "name": "myapp_data", "mounts": [{"service": "app", "target": "/data"}], } } out = container_volumes(_apps(meta), "my-app", "app") data = _parse(out) self.assertEqual(data, {"volumes": ["data:/data"]}) class TestContainerVolumesConfigSecret(unittest.TestCase): def test_config_renders_per_service_reference(self) -> None: meta = { "appcfg": { "type": "config", "source": "/opt/render/foo.yaml", "mode": "0440", "mounts": [{"service": "app", "target": "/app/foo.yaml"}], } } out = container_volumes(_apps(meta), "my-app", "app") data = _parse(out) self.assertEqual( data, { "configs": [ {"source": "appcfg", "target": "/app/foo.yaml", "mode": 0o440} ] }, ) def test_secret_renders_per_service_reference(self) -> None: meta = { "tlskey": { "type": "secret", "source": "/etc/ssl/key.pem", "mode": "0400", "mounts": [{"service": "app", "target": "/run/secrets/key"}], } } out = container_volumes(_apps(meta), "my-app", "app") data = _parse(out) self.assertEqual( data, { "secrets": [ {"source": "tlskey", "target": "/run/secrets/key", "mode": 0o400} ] }, ) class TestContainerVolumesTmpfs(unittest.TestCase): def test_tmpfs_size_renders_as_the_byte_count_the_long_form_takes(self) -> None: """Compose rejects "64m" outright: the long form takes an integer.""" meta = { "scratch": { "type": "tmpfs", "mounts": [{"service": "app", "target": "/scratch", "size": "64m"}], } } out = container_volumes(_apps(meta), "my-app", "app") data = _parse(out) self.assertEqual( data, { "volumes": [ { "type": "tmpfs", "target": "/scratch", "tmpfs": {"size": 64 * 1024**2}, } ] }, ) def test_every_unit_a_declaration_may_use_resolves(self) -> None: for written, expected in ( ("512m", 512 * 1024**2), ("64k", 64 * 1024), ("2g", 2 * 1024**3), ("900b", 900), ("900", 900), (1024, 1024), ): with self.subTest(size=written): meta = { "scratch": { "type": "tmpfs", "mounts": [ {"service": "app", "target": "/scratch", "size": written} ], } } data = _parse(container_volumes(_apps(meta), "my-app", "app")) self.assertEqual(expected, data["volumes"][0]["tmpfs"]["size"]) def test_a_size_that_is_not_a_byte_count_is_refused(self) -> None: """Silently passing it on is what let "512m" reach compose.""" for written in ("", "abc", "12x"): with self.subTest(size=written): meta = { "scratch": { "type": "tmpfs", "mounts": [ {"service": "app", "target": "/scratch", "size": written} ], } } with self.assertRaises(Exception): # noqa: B017 - the plugin raises an Ansible error type container_volumes(_apps(meta), "my-app", "app") def test_tmpfs_mode_renders_as_decimal_of_the_octal_string(self) -> None: meta = { "scratch": { "type": "tmpfs", "mounts": [ {"service": "app", "target": "/scratch", "mode": "01777"}, ], } } out = container_volumes(_apps(meta), "my-app", "app") data = _parse(out) self.assertEqual( data, { "volumes": [ { "type": "tmpfs", "target": "/scratch", "tmpfs": {"mode": 0o1777}, } ] }, ) class TestContainerVolumesServiceFilter(unittest.TestCase): def test_only_emits_mounts_for_requested_service(self) -> None: meta = { "cfg": { "type": "bind", "source": "/etc/foo.conf", "mounts": [ {"service": "a", "target": "/etc/foo.conf"}, {"service": "b", "target": "/opt/foo.conf"}, ], } } out_a = container_volumes(_apps(meta), "my-app", "a") out_b = container_volumes(_apps(meta), "my-app", "b") self.assertEqual(_parse(out_a), {"volumes": ["/etc/foo.conf:/etc/foo.conf"]}) self.assertEqual(_parse(out_b), {"volumes": ["/etc/foo.conf:/opt/foo.conf"]}) class TestContainerVolumesWhen(unittest.TestCase): def test_when_false_filters_out(self) -> None: meta = { "cfg": { "type": "bind", "source": "/etc/foo.conf", "mounts": [ {"service": "app", "target": "/etc/foo.conf", "when": False}, ], } } out = container_volumes(_apps(meta), "my-app", "app") self.assertEqual(out, "") def test_when_true_keeps(self) -> None: meta = { "cfg": { "type": "bind", "source": "/etc/foo.conf", "mounts": [ {"service": "app", "target": "/etc/foo.conf", "when": True}, ], } } out = container_volumes(_apps(meta), "my-app", "app") self.assertEqual(_parse(out), {"volumes": ["/etc/foo.conf:/etc/foo.conf"]}) def test_when_string_falsy_filters_out(self) -> None: meta = { "cfg": { "type": "bind", "source": "/etc/foo.conf", "mounts": [ {"service": "app", "target": "/etc/foo.conf", "when": "false"}, ], } } out = container_volumes(_apps(meta), "my-app", "app") self.assertEqual(out, "") def test_when_jinja_evaluated_via_render_callable(self) -> None: """Callers (lookup plugin) pass a templar-backed render callable; verifies the contract: a Jinja expression resolves to a usable truthy/falsy value through that callable.""" rendered_calls: list[str] = [] def render_jinja(expr: str) -> str: rendered_calls.append(expr) return "true" if "FLAG" in expr else "false" meta = { "cfg": { "type": "bind", "source": "/a", "mounts": [ { "service": "app", "target": "/a", "when": "{{ FLAG | bool }}", }, { "service": "app", "target": "/b", "when": "{{ OTHER | bool }}", }, ], } } out = container_volumes(_apps(meta), "my-app", "app", render_jinja=render_jinja) self.assertEqual(_parse(out), {"volumes": ["/a:/a"]}) self.assertEqual(rendered_calls, ["{{ FLAG | bool }}", "{{ OTHER | bool }}"]) class TestContainerVolumesExtras(unittest.TestCase): def test_extra_volumes_appended(self) -> None: meta = { "data": { "type": "volume", "name": "myapp_data", "mounts": [{"service": "app", "target": "/data"}], } } out = container_volumes( _apps(meta), "my-app", "app", extra_volumes=["/host:/extra:ro"] ) self.assertEqual( _parse(out), {"volumes": ["data:/data", "/host:/extra:ro"]}, ) def test_extra_configs_and_secrets(self) -> None: out = container_volumes( _apps(None), "my-app", "app", extra_configs=[{"source": "cfg", "target": "/etc/x"}], extra_secrets=[{"source": "s", "target": "/run/secrets/y"}], ) data = _parse(out) self.assertEqual( data, { "configs": [{"source": "cfg", "target": "/etc/x"}], "secrets": [{"source": "s", "target": "/run/secrets/y"}], }, ) def test_extras_handed_over_as_templating_proxies_still_serialize(self) -> None: class ProxyMapping(Mapping): def __init__(self, data): self._data = data def __getitem__(self, key): return self._data[key] def __iter__(self): return iter(self._data) def __len__(self): return len(self._data) class TaggedInt(int): pass out = container_volumes( _apps(None), "my-app", "app", extra_secrets=[ ProxyMapping( {"source": "s", "target": "/run/y", "mode": TaggedInt(0o444)} ) ], ) self.assertEqual( _parse(out), {"secrets": [{"source": "s", "target": "/run/y", "mode": 0o444}]}, ) class TestContainerVolumesOutputShape(unittest.TestCase): def test_empty_meta_returns_empty_string(self) -> None: out = container_volumes(_apps(None), "my-app", "app") self.assertEqual(out, "") def test_output_starts_at_column_0(self) -> None: meta = { "data": { "type": "volume", "name": "x", "mounts": [{"service": "app", "target": "/data"}], } } out = container_volumes(_apps(meta), "my-app", "app") first_line = out.splitlines()[0] self.assertFalse(first_line.startswith(" ")) self.assertEqual(first_line, "volumes:") if __name__ == "__main__": # pragma: no cover unittest.main()