mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
compose_volumes copied extra_configs and extra_secrets through verbatim.
A file-backed extra without a name therefore reached swarm under a fixed
name, and swarm secrets are immutable: a new content under the same name
is rejected on the next stack deploy. Such extras now get the same
{role}_{key}_{sha8(content)} name that _config_secret_name already gives
meta-declared entries. An explicit name is kept.
container_volumes appended extras as handed over. Ansible passes one
lookup's result to another as proxy containers, and a TaggedInt mode
inside a proxy mapping made PyYAML raise "cannot represent an object".
The private _to_plain of compose_volumes moves to utils/templating as
to_plain, now coercing bool, int and float to the exact builtin types
(bool before int, since bool subclasses int), and container_volumes runs
its extras through it.
Only web-app-matrix passes extra secrets or configs today; the
extra_volumes path that web-app-nextcloud uses is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
389 lines
13 KiB
Python
389 lines
13 KiB
Python
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()
|