mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
Output of `make quality-high`, which rewrites the tree before it tests it. Formatting only: the YAML null goes from `~` to an empty value, which parses the same, and the Python changes are line reflow plus the parenthesized `with` groups. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""The `resource` lookup measures and computes every host-resource constant.
|
|
|
|
Ansible's Linux hardware collector reads `/sys/block/<dev>/size` without a None
|
|
guard, and that read runs before the CPU facts in the same collector, so one
|
|
unreadable block device drops every hardware fact at once and without a warning.
|
|
Templates that dereferenced `processor_vcpus` or `memtotal_mb` died on the first
|
|
render.
|
|
|
|
These tests pin the three properties that make the replacement safe: the values
|
|
resolve with no hardware facts at all, the arithmetic matches the Jinja it
|
|
replaces, and nothing is ever substituted for a value that could not be
|
|
measured.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from typing import ClassVar
|
|
from unittest.mock import patch
|
|
|
|
from ansible.errors import AnsibleError
|
|
|
|
from plugins.lookup.resource import LookupModule
|
|
from utils.cache.errors import UnresolvableValueError
|
|
|
|
_CPUS = 12
|
|
_MEM_MB = 64000
|
|
|
|
|
|
def _vars(**overrides):
|
|
"""A play scope whose hardware collector died: facts present, none of them hardware."""
|
|
scope = {
|
|
"ansible_facts": {"os_family": "Debian", "service_mgr": "systemd"},
|
|
"ansible_connection": "local",
|
|
"inventory_hostname": "test-host",
|
|
"applications": {},
|
|
"group_names": [],
|
|
}
|
|
scope.update(overrides)
|
|
return scope
|
|
|
|
|
|
def _run(terms, variables=None, **kwargs):
|
|
lookup = LookupModule()
|
|
lookup._templar = None
|
|
with (
|
|
patch("plugins.lookup.resource.os.cpu_count", return_value=_CPUS),
|
|
patch("plugins.lookup.resource.mem_total_mb", return_value=_MEM_MB),
|
|
):
|
|
return lookup.run(terms, variables=variables or _vars(), **kwargs)
|
|
|
|
|
|
class TestResourceMeasurement(unittest.TestCase):
|
|
def test_resolves_without_any_hardware_fact(self) -> None:
|
|
self.assertEqual(_run(["host_cpus", "host_mem_mb"]), [_CPUS, _MEM_MB])
|
|
|
|
def test_hardware_facts_win_over_the_local_probe(self) -> None:
|
|
scope = _vars(
|
|
ansible_facts={"processor_vcpus": 4, "memtotal_mb": 8000},
|
|
)
|
|
self.assertEqual(_run(["host_cpus", "host_mem_mb"], scope), [4, 8000])
|
|
|
|
def test_inventory_override_wins_over_the_facts(self) -> None:
|
|
scope = _vars(
|
|
ansible_facts={"processor_vcpus": 4, "memtotal_mb": 8000},
|
|
RESOURCE_HOST_CPUS_OVERRIDE=7,
|
|
RESOURCE_HOST_MEM_MB_OVERRIDE=1234,
|
|
)
|
|
self.assertEqual(_run(["host_cpus", "host_mem_mb"], scope), [7, 1234])
|
|
|
|
def test_a_remote_host_without_facts_raises_instead_of_probing(self) -> None:
|
|
scope = _vars(ansible_connection="ssh", inventory_hostname="remote-box")
|
|
with self.assertRaises(UnresolvableValueError) as caught:
|
|
_run(["host_cpus"], scope)
|
|
message = str(caught.exception)
|
|
self.assertIn("remote-box", message)
|
|
self.assertIn("RESOURCE_HOST_CPUS_OVERRIDE", message)
|
|
|
|
def test_a_remote_host_with_an_override_resolves(self) -> None:
|
|
scope = _vars(
|
|
ansible_connection="ssh",
|
|
RESOURCE_HOST_CPUS_OVERRIDE=3,
|
|
RESOURCE_HOST_MEM_MB_OVERRIDE=2048,
|
|
)
|
|
self.assertEqual(_run(["host_cpus", "host_mem_mb"], scope), [3, 2048])
|
|
|
|
def test_an_unusable_probe_raises(self) -> None:
|
|
lookup = LookupModule()
|
|
lookup._templar = None
|
|
with (
|
|
patch("plugins.lookup.resource.os.cpu_count", return_value=0),
|
|
self.assertRaises(UnresolvableValueError) as caught,
|
|
):
|
|
lookup.run(["host_cpus"], variables=_vars())
|
|
self.assertIn("RESOURCE_HOST_CPUS_OVERRIDE", str(caught.exception))
|
|
|
|
def test_a_zero_override_is_rejected_rather_than_used(self) -> None:
|
|
scope = _vars(ansible_connection="ssh", RESOURCE_HOST_CPUS_OVERRIDE=0)
|
|
with self.assertRaises(UnresolvableValueError):
|
|
_run(["host_cpus"], scope)
|
|
|
|
def test_a_hostless_render_fails_softly(self) -> None:
|
|
"""Without a host nothing is being deployed, so nothing may be aborted.
|
|
|
|
The inventory generator and the lint suite render the whole applications
|
|
payload with no host in scope, and the meta files of nextcloud and
|
|
svc-opt-swapfile sit in that payload. A non-swallowable abort there would
|
|
fail tooling over a value it never reads.
|
|
"""
|
|
scope = _vars(ansible_connection="ssh")
|
|
del scope["inventory_hostname"]
|
|
with self.assertRaises(AnsibleError) as caught:
|
|
_run(["host_cpus"], scope)
|
|
self.assertNotIsInstance(
|
|
caught.exception,
|
|
UnresolvableValueError,
|
|
"A hostless render must raise the swallowable class so best-effort "
|
|
"rendering absorbs it, exactly as the missing fact did before.",
|
|
)
|
|
|
|
|
|
class TestResourceArithmetic(unittest.TestCase):
|
|
"""Every expected value is the Jinja expression this lookup replaced."""
|
|
|
|
def test_host_memory_is_floored_to_whole_gigabytes(self) -> None:
|
|
self.assertEqual(_run(["host_mem"]), [_MEM_MB // 1024])
|
|
|
|
def test_available_resources_subtract_the_reserve(self) -> None:
|
|
self.assertEqual(
|
|
_run(["avail_cpus", "avail_mem"]),
|
|
[_CPUS - 2, (_MEM_MB // 1024) - 4],
|
|
)
|
|
|
|
def test_per_container_shares_divide_by_the_active_count(self) -> None:
|
|
avail_cpus = _CPUS - 2
|
|
avail_mem = (_MEM_MB // 1024) - 4
|
|
self.assertEqual(
|
|
_run(["cpus", "mem_reservation", "mem_limit"]),
|
|
[
|
|
max(round(avail_cpus / 1, 2), 0.5),
|
|
f"{round(avail_mem / 1 * 0.7, 1)}g",
|
|
f"{round(avail_mem / 1 * 1.0, 1)}g",
|
|
],
|
|
)
|
|
|
|
def test_a_cpu_share_never_falls_below_the_floor(self) -> None:
|
|
scope = _vars(RESOURCE_HOST_CPUS_OVERRIDE=2)
|
|
self.assertEqual(_run(["cpus"], scope), [0.5])
|
|
|
|
def test_postgres_connections_cap_at_four_hundred(self) -> None:
|
|
self.assertEqual(
|
|
_run(["postgres_max_connections"]), [min(_CPUS * 30 + 50, 400)]
|
|
)
|
|
|
|
def test_postgres_memory_matches_the_replaced_expressions(self) -> None:
|
|
max_connections = min(_CPUS * 30 + 50, 400)
|
|
self.assertEqual(
|
|
_run(
|
|
[
|
|
"postgres_shared_buffers",
|
|
"postgres_work_mem",
|
|
"postgres_maintenance_work_mem",
|
|
]
|
|
),
|
|
[
|
|
f"{_MEM_MB * 25 // 100}MB",
|
|
f"{max(_MEM_MB // max(max_connections, 1) // 2, 1)}MB",
|
|
f"{max(_MEM_MB * 5 // 100, 64)}MB",
|
|
],
|
|
)
|
|
|
|
def test_rounding_goes_half_up_like_jinja(self) -> None:
|
|
from plugins.lookup.resource import _round_half_up
|
|
|
|
self.assertEqual(_round_half_up(0.125, 2), 0.13)
|
|
self.assertEqual(_round_half_up(2.5, 0), 3.0)
|
|
|
|
|
|
class TestResourceApplicationsAccess(unittest.TestCase):
|
|
"""`applications` is read only for the keys that divide by the container count.
|
|
|
|
The meta files of web-app-nextcloud and svc-opt-swapfile read their memory
|
|
back out of this lookup while the applications payload is being rendered.
|
|
Touching `applications` for those keys would make that render re-enter
|
|
itself.
|
|
"""
|
|
|
|
class _Tripwire(dict):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.read = False
|
|
|
|
def __getitem__(self, key):
|
|
if key == "applications":
|
|
self.read = True
|
|
return super().__getitem__(key)
|
|
|
|
def get(self, key, default=None):
|
|
if key == "applications":
|
|
self.read = True
|
|
return super().get(key, default)
|
|
|
|
def _scope(self):
|
|
scope = self._Tripwire()
|
|
scope.update(_vars())
|
|
return scope
|
|
|
|
def test_host_and_postgres_keys_never_read_applications(self) -> None:
|
|
for key in (
|
|
"host_cpus",
|
|
"host_mem_mb",
|
|
"postgres_max_connections",
|
|
"postgres_shared_buffers",
|
|
"pids_limit",
|
|
):
|
|
with self.subTest(key=key):
|
|
scope = self._scope()
|
|
_run([key], scope)
|
|
self.assertFalse(
|
|
scope.read,
|
|
f"'{key}' read 'applications'. That re-enters the "
|
|
f"applications render, which is what reads this key.",
|
|
)
|
|
|
|
def test_share_keys_do_read_applications(self) -> None:
|
|
scope = self._scope()
|
|
_run(["cpus"], scope)
|
|
self.assertTrue(
|
|
scope.read,
|
|
"'cpus' divides by the active container count, so it must read "
|
|
"'applications'; a silent 1 would size every container wrong.",
|
|
)
|
|
|
|
|
|
class TestUnresolvableSurvivesBestEffortRender(unittest.TestCase):
|
|
"""A best-effort render must not turn "cannot measure" into an empty value.
|
|
|
|
`utils.cache.base._render_with_templar` and the two templar helpers behind
|
|
it swallow every exception so a meta payload can render in rounds. The meta
|
|
files of web-app-nextcloud and svc-opt-swapfile read their memory through
|
|
this lookup, so without an exemption the abort would be silently absorbed
|
|
exactly where the wrong number does the most damage.
|
|
"""
|
|
|
|
def test_the_render_helpers_re_raise_it(self) -> None:
|
|
from utils.cache.base import _render_with_templar
|
|
|
|
class _ExplodingTemplar:
|
|
available_variables: ClassVar[dict] = {}
|
|
|
|
def template(self, *_args, **_kwargs):
|
|
raise UnresolvableValueError("resource: cannot determine the host CPUs")
|
|
|
|
with self.assertRaises(UnresolvableValueError):
|
|
_render_with_templar(
|
|
"{{ lookup('resource', 'host_cpus') }}",
|
|
templar=_ExplodingTemplar(),
|
|
variables={},
|
|
)
|
|
|
|
def test_an_ordinary_failure_is_still_swallowed(self) -> None:
|
|
from utils.cache.base import _render_with_templar
|
|
|
|
class _ExplodingTemplar:
|
|
available_variables: ClassVar[dict] = {}
|
|
|
|
def template(self, *_args, **_kwargs):
|
|
raise ValueError("some expression that cannot resolve yet")
|
|
|
|
try:
|
|
_render_with_templar(
|
|
"{{ SOMETHING_NOT_READY }}",
|
|
templar=_ExplodingTemplar(),
|
|
variables={},
|
|
)
|
|
except Exception as exc:
|
|
self.fail(
|
|
f"A value that merely cannot resolve in this round must be "
|
|
f"absorbed so the next round can retry it. Only an unmeasurable "
|
|
f"host value aborts. Got {exc!r}."
|
|
)
|
|
|
|
def test_it_is_found_through_ansibles_wrapper(self) -> None:
|
|
from utils.cache.errors import is_unresolvable
|
|
|
|
original = UnresolvableValueError("resource: cannot determine the host CPUs")
|
|
try:
|
|
try:
|
|
raise original
|
|
except UnresolvableValueError as exc:
|
|
raise AnsibleError("The lookup plugin 'resource' failed") from exc
|
|
except AnsibleError as wrapped:
|
|
self.assertTrue(
|
|
is_unresolvable(wrapped),
|
|
"Ansible wraps an exception raised inside a lookup, so the "
|
|
"original is only reachable through the cause chain.",
|
|
)
|
|
|
|
def test_an_unrelated_error_is_not_mistaken_for_it(self) -> None:
|
|
from utils.cache.errors import is_unresolvable
|
|
|
|
self.assertFalse(is_unresolvable(ValueError("unrelated")))
|
|
self.assertFalse(is_unresolvable(None))
|
|
|
|
|
|
class TestResourceInterface(unittest.TestCase):
|
|
def test_an_unknown_key_names_the_valid_ones(self) -> None:
|
|
with self.assertRaises(AnsibleError) as caught:
|
|
_run(["not_a_resource"])
|
|
message = str(caught.exception)
|
|
self.assertIn("not_a_resource", message)
|
|
self.assertIn("host_cpus", message)
|
|
|
|
def test_no_terms_returns_nothing(self) -> None:
|
|
self.assertEqual(_run([]), [])
|
|
|
|
def test_a_single_list_term_is_expanded(self) -> None:
|
|
self.assertEqual(_run([["host_cpus", "host_mem_mb"]]), [_CPUS, _MEM_MB])
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
unittest.main()
|