mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 12:17:13 +00:00
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>
263 lines
9.1 KiB
Python
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()
|