Files
computer-playbook/tests/unit/python/utils/roles/applications/test_mounts.py
T
kevinveenbirkenbachandClaude Opus 5.5 260ebf7357 fix(backup): ask every rendered service for its stop decision
test_backup_stop_decision_declared looked a volume mount's `service` up
as a meta/services.yml key. A role whose compose service is renamed via
`proxy.service_key` slipped through: web-svc-xmpp mounts `ejabberd`, its
entry is `xmpp`, and the lint never asked it to decide.

- compose_service_names() in utils/roles/applications/mounts.py is the
  shared mapping from a meta key to the compose services it renders as
  (the key plus a literal proxy.service_key); the lint resolves each
  backed-up mount to its owning keys through it, and
  test_image_volumes_mounted uses it instead of its own copy.
- web-svc-xmpp stops before the copy (`/opt/ejabberd/database` is a
  Mnesia store spread over several files), which is what the missing
  marker already did. Its upload volume is no longer backed up: the
  ejabberd config enables no mod_http_upload, so nothing writes there.
- web-app-pretix deploys pretix_custom:3.13, the reference
  lookup('backup_image', 'stop_required', false) resolves, instead of
  overriding the tag with the source version. The override made the
  deployed image miss --images-no-stop-required, so baudolo stopped
  Pretix despite `stop_required: false`. The source version moves into
  the build args, which keeps a bump rebuilding through the build
  digest, and into an OCI version label on the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:41:45 +01:00

263 lines
9.1 KiB
Python

from __future__ import annotations
import unittest
from utils.roles.applications.mounts import (
VolumesSchemaError,
compose_service_names,
content_hash,
mount_default_read_only,
mounts_for_service,
normalize_volumes_meta,
validate_volumes_meta,
)
class TestNormalizeVolumesMeta(unittest.TestCase):
def test_none_returns_empty(self) -> None:
self.assertEqual(normalize_volumes_meta(None), {})
def test_canonical_dict_shape(self) -> None:
canonical = {
"data": {
"type": "volume",
"name": "matrix_synapse_data",
"mounts": [{"service": "synapse", "target": "/data"}],
},
"config": {
"type": "bind",
"source": "/etc/synapse/config",
"read_only": True,
"mounts": [{"service": "synapse", "target": "/etc/synapse/config"}],
},
}
result = normalize_volumes_meta(canonical)
self.assertEqual(set(result.keys()), {"data", "config"})
self.assertEqual(result["data"]["type"], "volume")
self.assertEqual(result["data"]["name"], "matrix_synapse_data")
self.assertEqual(result["config"]["type"], "bind")
self.assertEqual(result["config"]["source"], "/etc/synapse/config")
def test_default_type_volume(self) -> None:
result = normalize_volumes_meta({"data": {}})
self.assertEqual(result["data"]["type"], "volume")
def test_docker_name_optional(self) -> None:
result = normalize_volumes_meta({"data": {"type": "volume"}})
self.assertNotIn("name", result["data"])
def test_list_shape_rejected(self) -> None:
with self.assertRaises(VolumesSchemaError) as ctx:
normalize_volumes_meta([{"name": "x", "type": "volume"}])
self.assertIn("list-shape", str(ctx.exception))
def test_invalid_root_shape_raises(self) -> None:
with self.assertRaises(VolumesSchemaError):
normalize_volumes_meta("foo")
def test_entry_must_be_dict(self) -> None:
with self.assertRaises(VolumesSchemaError):
normalize_volumes_meta({"data": "not-a-dict"})
def test_empty_key_rejected(self) -> None:
with self.assertRaises(VolumesSchemaError):
normalize_volumes_meta({"": {"type": "volume"}})
class TestValidateVolumesMeta(unittest.TestCase):
def test_clean_dict_passes(self) -> None:
result = validate_volumes_meta(
{"data": {"type": "volume", "name": "myapp_data"}},
"test-role",
)
self.assertEqual(result, [])
def test_clean_canonical_passes(self) -> None:
result = validate_volumes_meta(
{
"cfg": {
"type": "config",
"source": "/etc/foo.yml",
"mode": "0440",
},
"data": {"type": "volume", "nfs": True, "read_only": False},
},
"test-role",
)
self.assertEqual(result, [])
def test_unknown_type_flagged(self) -> None:
violations = validate_volumes_meta(
{"x": {"type": "weird"}},
"r",
)
self.assertTrue(any("'type' must be one of" in v for v in violations))
def test_bind_requires_source(self) -> None:
violations = validate_volumes_meta(
{"x": {"type": "bind"}},
"r",
)
self.assertTrue(any("requires a non-empty 'source'" in v for v in violations))
def test_volume_forbids_source(self) -> None:
violations = validate_volumes_meta(
{"x": {"type": "volume", "source": "/foo"}},
"r",
)
self.assertTrue(any("MUST NOT carry 'source'" in v for v in violations))
def test_mode_only_on_config_secret(self) -> None:
violations = validate_volumes_meta(
{"x": {"type": "volume", "mode": "0440"}},
"r",
)
self.assertTrue(any("'mode' is only valid for" in v for v in violations))
def test_mode_must_be_octal_string(self) -> None:
violations = validate_volumes_meta(
{"x": {"type": "config", "source": "/a", "mode": "440"}},
"r",
)
self.assertTrue(any("octal string" in v for v in violations))
def test_read_only_only_on_bind_volume(self) -> None:
violations = validate_volumes_meta(
{"x": {"type": "config", "source": "/a", "read_only": True}},
"r",
)
self.assertTrue(any("'read_only' is only valid for" in v for v in violations))
def test_nfs_dict_form_accepted(self) -> None:
result = validate_volumes_meta(
{
"data": {
"type": "volume",
"nfs": {"uid": 33, "gid": 33, "mode": "0755"},
}
},
"r",
)
self.assertEqual(result, [])
def test_swarm_safe_only_on_bind(self) -> None:
violations = validate_volumes_meta(
{"x": {"type": "volume", "swarm_safe": False}},
"r",
)
self.assertTrue(any("'swarm_safe' opt-out is only" in v for v in violations))
def test_backup_only_on_volume(self) -> None:
violations = validate_volumes_meta(
{"x": {"type": "config", "source": "/a", "backup": False}},
"r",
)
self.assertTrue(any("'backup' is only valid for" in v for v in violations))
def test_backup_must_be_bool(self) -> None:
violations = validate_volumes_meta(
{"x": {"type": "volume", "backup": "false"}},
"r",
)
self.assertTrue(any("'backup' must be bool" in v for v in violations))
def test_backup_false_on_a_volume_passes(self) -> None:
result = validate_volumes_meta(
{"x": {"type": "volume", "backup": False}},
"r",
)
self.assertEqual(result, [])
def test_mount_requires_service_and_target(self) -> None:
violations = validate_volumes_meta(
{
"x": {
"type": "config",
"source": "/a",
"mounts": [{"target": "/etc/x"}],
}
},
"r",
)
self.assertTrue(any("'service' is required" in v for v in violations))
def test_docker_name_must_be_non_empty_string(self) -> None:
violations = validate_volumes_meta(
{"x": {"type": "volume", "name": ""}},
"r",
)
self.assertTrue(
any(
"'name' (container volume name) must be a non-empty string" in v
for v in violations
)
)
def test_list_shape_returns_violation(self) -> None:
violations = validate_volumes_meta(
[{"name": "x", "type": "volume"}],
"r",
)
self.assertEqual(len(violations), 1)
self.assertIn("list-shape", violations[0])
class TestContentHash(unittest.TestCase):
def test_stable_for_same_input(self) -> None:
self.assertEqual(content_hash("foo"), content_hash("foo"))
def test_differs_for_different_input(self) -> None:
self.assertNotEqual(content_hash("foo"), content_hash("bar"))
def test_default_length_8(self) -> None:
self.assertEqual(len(content_hash("anything")), 8)
class TestDefaults(unittest.TestCase):
def test_config_default_read_only(self) -> None:
self.assertTrue(mount_default_read_only({"type": "config"}))
def test_secret_default_read_only(self) -> None:
self.assertTrue(mount_default_read_only({"type": "secret"}))
def test_bind_default_rw(self) -> None:
self.assertFalse(mount_default_read_only({"type": "bind"}))
def test_volume_default_rw(self) -> None:
self.assertFalse(mount_default_read_only({"type": "volume"}))
class TestMountsForService(unittest.TestCase):
def test_filters_by_service(self) -> None:
entry = {
"mounts": [
{"service": "a", "target": "/x"},
{"service": "b", "target": "/y"},
{"service": "a", "target": "/z"},
]
}
result = list(mounts_for_service(entry, "a"))
self.assertEqual(len(result), 2)
targets = sorted(m["target"] for m in result)
self.assertEqual(targets, ["/x", "/z"])
class TestComposeServiceNames(unittest.TestCase):
def test_an_entry_without_a_proxy_is_only_its_own_key(self) -> None:
self.assertEqual(compose_service_names("app", {"image": "app"}), {"app"})
def test_a_proxy_service_key_joins_the_entry_key(self) -> None:
entry = {"proxy": {"service_key": "ejabberd"}}
self.assertEqual(compose_service_names("xmpp", entry), {"xmpp", "ejabberd"})
def test_a_templated_service_key_leaves_only_the_entry_key(self) -> None:
entry = {"proxy": {"service_key": "{{ 'synapse' if a else 'matrix' }}"}}
self.assertEqual(compose_service_names("app", entry), {"app"})
def test_a_non_mapping_entry_is_only_its_key(self) -> None:
self.assertEqual(compose_service_names("app", None), {"app"})
if __name__ == "__main__": # pragma: no cover
unittest.main()