diff --git a/plugins/filter/memory_filters.py b/plugins/filter/memory_filters.py deleted file mode 100644 index e62cdc5df2..0000000000 --- a/plugins/filter/memory_filters.py +++ /dev/null @@ -1,184 +0,0 @@ -from __future__ import annotations - -import re - -from ansible.errors import AnsibleFilterError - -from utils.roles.applications.config import get -from utils.roles.entity.name import get_entity_name - -_UNIT_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([kKmMgGtT]?[bB]?)?\s*$") -_FACTORS = { - "": 1, - "b": 1, - "k": 1024, - "kb": 1024, - "m": 1024**2, - "mb": 1024**2, - "g": 1024**3, - "gb": 1024**3, - "t": 1024**4, - "tb": 1024**4, -} - -# ------------------------------------------------------ -# Helpers: unit conversion -# ------------------------------------------------------ - - -def _to_bytes(v: str) -> int: - """Convert a human-readable size string (e.g., '2g', '512m') to bytes.""" - if v is None: - raise AnsibleFilterError("memory_filters: size value is None") - - s = str(v).strip() - m = _UNIT_RE.match(s) - if not m: - raise AnsibleFilterError(f"memory_filters: invalid size '{v}'") - - num, unit = m.group(1), (m.group(2) or "").lower() - - try: - val = float(num) - except ValueError as e: - raise AnsibleFilterError(f"memory_filters: invalid numeric size '{v}'") from e - - factor = _FACTORS.get(unit) - if factor is None: - raise AnsibleFilterError(f"memory_filters: unknown unit in '{v}'") - - return int(val * factor) - - -def _to_mb(v: str) -> int: - """Convert human-readable size to megabytes.""" - return max(0, _to_bytes(v) // (1024 * 1024)) - - -# ------------------------------------------------------ -# JVM-specific helpers -# ------------------------------------------------------ - - -def _svc(app_id: str) -> str: - """Resolve the internal service name for JVM-based applications.""" - return get_entity_name(app_id) - - -def _mem_limit_mb(apps: dict, app_id: str) -> int: - """Resolve mem_limit for the JVM service of the given application.""" - svc = _svc(app_id) - raw = get(apps, app_id, f"services.{svc}.mem_limit") - mb = _to_mb(raw) - - if mb <= 0: - raise AnsibleFilterError( - f"memory_filters: mem_limit for '{svc}' must be > 0 MB (got '{raw}')" - ) - return mb - - -def _mem_res_mb(apps: dict, app_id: str) -> int: - """Resolve mem_reservation for the JVM service of the given application.""" - svc = _svc(app_id) - raw = get(apps, app_id, f"services.{svc}.mem_reservation") - mb = _to_mb(raw) - - if mb <= 0: - raise AnsibleFilterError( - f"memory_filters: mem_reservation for '{svc}' must be > 0 MB (got '{raw}')" - ) - return mb - - -def jvm_max_mb(apps: dict, app_id: str) -> int: - """ - Compute recommended JVM Xmx in MB using: - Xmx = min( - floor(0.7 * mem_limit), - mem_limit - 1024, - 12288 - ) - with a lower bound of 1024 MB. - """ - limit_mb = _mem_limit_mb(apps, app_id) - c1 = (limit_mb * 7) // 10 - c2 = max(0, limit_mb - 1024) - c3 = 12288 - - return max(1024, min(c1, c2, c3)) - - -def jvm_min_mb(apps: dict, app_id: str) -> int: - """ - Compute recommended JVM Xms in MB using: - Xms = min( - floor(Xmx / 2), - mem_reservation, - Xmx - ) - with a lower bound of 512 MB. - """ - xmx = jvm_max_mb(apps, app_id) - res = _mem_res_mb(apps, app_id) - - return max(512, min(xmx // 2, res, xmx)) - - -# ------------------------------------------------------ -# Redis-specific helpers (always service name "redis") -# ------------------------------------------------------ - - -def _redis_mem_limit_mb(apps: dict, app_id: str, default_mb: int = 256) -> int: - """ - Resolve mem_limit for the Redis service of an application. - Unlike JVM-based services, Redis always uses the service name "redis". - - If no mem_limit is defined, fall back to default_mb. - """ - raw = get( - apps, - app_id, - "services.redis.mem_limit", - strict=False, - default=f"{default_mb}m", - ) - - mb = _to_mb(raw) - - if mb <= 0: - raise AnsibleFilterError( - f"memory_filters: mem_limit for 'redis' must be > 0 MB (got '{raw}')" - ) - - return mb - - -def redis_maxmemory_mb( - apps: dict, app_id: str, factor: float = 0.8, min_mb: int = 64 -) -> int: - """ - Compute recommended Redis `maxmemory` in MB. - - * factor: fraction of allowed memory used for Redis data (default 0.8) - * min_mb: minimum floor value (default 64 MB) - - maxmemory = max(min_mb, floor(factor * mem_limit)) - """ - limit_mb = _redis_mem_limit_mb(apps, app_id) - return max(min_mb, int(limit_mb * factor)) - - -# ------------------------------------------------------ -# Filter module -# ------------------------------------------------------ - - -class FilterModule: - def filters(self): - return { - "jvm_max_mb": jvm_max_mb, - "jvm_min_mb": jvm_min_mb, - "redis_maxmemory_mb": redis_maxmemory_mb, - } diff --git a/plugins/lookup/service_memory_mb.py b/plugins/lookup/service_memory_mb.py new file mode 100644 index 0000000000..2d2f82786e --- /dev/null +++ b/plugins/lookup/service_memory_mb.py @@ -0,0 +1,106 @@ +"""Derive a memory parameter in MiB from the memory limit of a service's container. + + -Xmx{{ lookup('service_memory_mb', application_id, 'search', 0.5) }}m + memory_limit = {{ lookup('service_memory_mb', application_id, entity_name, 0.5) }}M + +The limit is resolved the way ``sys-svc-container`` resolves the container's own +``mem_limit``: ``services..mem_limit``, then the role's primary entity, +then ``RESOURCE_MEM_LIMIT``. ``share`` is the fraction of that limit the +parameter may claim, so a heap, a cache or a per-request limit follows the +container instead of a literal that drifts away from it. + +Sizes are read like Docker reads them: ``k``, ``m`` and ``g`` are binary, a bare +number is bytes. The result is whole MiB, the unit ``-Xmxm``, PHP ``M``, +``memcached -m``, Redis ``mb`` and PostgreSQL ``MB`` all share. +""" + +from __future__ import annotations + +import re +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase + +from plugins.filter.resource_filter import resource_filter + +_SIZE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([bkmg]?)b?\s*$", re.IGNORECASE) +_FACTOR = {"": 1, "b": 1, "k": 1024, "m": 1024**2, "g": 1024**3} +_MEBIBYTE = 1024**2 +_HOST_DEFAULT = "RESOURCE_MEM_LIMIT" +_UNDECLARED = object() + + +def docker_bytes(size: Any) -> int: + """Return a Docker memory size in bytes. + + Args: + size: a size such as ``2g``, ``1.5g``, ``512m`` or a byte count. + + Raises: + AnsibleError: the size is not a number with an optional b/k/m/g unit. + """ + match = _SIZE.match(str(size)) + if match is None: + raise AnsibleError(f"lookup('service_memory_mb'): unreadable size {size!r}") + return int(float(match.group(1)) * _FACTOR[match.group(2).lower()]) + + +def share_mb(mem_limit: Any, share: Any) -> int: + """Return ``share`` of ``mem_limit`` in whole MiB. + + Args: + mem_limit: Docker size of the container limit, e.g. ``2g`` or ``512m``. + share: fraction of the limit, above 0 and at most 1. + + Raises: + AnsibleError: the share is no fraction in that range, the size is + unreadable, or the result is below 1 MiB. + """ + try: + fraction = float(share) + except (TypeError, ValueError) as exc: + raise AnsibleError( + f"lookup('service_memory_mb'): share {share!r} is not a number" + ) from exc + if not 0 < fraction <= 1: + raise AnsibleError( + f"lookup('service_memory_mb'): share {share!r} must be above 0 and at most 1" + ) + megabytes = int(docker_bytes(mem_limit) * fraction) // _MEBIBYTE + if megabytes < 1: + raise AnsibleError( + f"lookup('service_memory_mb'): {share!r} of {mem_limit!r} is below 1 MiB" + ) + return megabytes + + +class LookupModule(LookupBase): + """lookup('service_memory_mb', application_id, service_name, share)""" + + def run(self, terms, variables: dict[str, Any] | None = None, **kwargs): + if len(terms) != 3: + raise AnsibleError( + "lookup('service_memory_mb', application_id, service_name, share) " + "expects exactly 3 positional terms." + ) + application_id, service_name, share = terms + + templar = self._templar + variables = variables or templar.available_variables + applications = lookup_loader.get( + "applications", loader=self._loader, templar=templar + ).run([], variables=variables)[0] + + mem_limit = resource_filter( + applications, application_id, "mem_limit", service_name, _UNDECLARED + ) + if mem_limit is _UNDECLARED: + if _HOST_DEFAULT not in variables: + raise AnsibleError( + f"lookup('service_memory_mb'): '{application_id}' declares no " + f"mem_limit for '{service_name}' and {_HOST_DEFAULT} is unset" + ) + mem_limit = templar.template(variables[_HOST_DEFAULT]) + return [share_mb(mem_limit, share)] diff --git a/roles/svc-db-redis/templates/compose.yml.j2 b/roles/svc-db-redis/templates/compose.yml.j2 index efdbf2ef51..ce32003147 100644 --- a/roles/svc-db-redis/templates/compose.yml.j2 +++ b/roles/svc-db-redis/templates/compose.yml.j2 @@ -10,7 +10,7 @@ exec redis-server --requirepass {{ REDIS_PASSWORD | quote }} --appendonly yes - --maxmemory {{ lookup('applications') | redis_maxmemory_mb(application_id, 0.8, RESOURCE_MEM_LIMIT | int) }}mb + --maxmemory {{ lookup('service_memory_mb', application_id, service_name, 0.8) }}mb --maxmemory-policy allkeys-lru {% include 'roles/sys-svc-container/templates/base.yml.j2' %} diff --git a/roles/svc-db-redis/templates/service.yml.j2 b/roles/svc-db-redis/templates/service.yml.j2 index e678ee590e..d95e5e21e7 100644 --- a/roles/svc-db-redis/templates/service.yml.j2 +++ b/roles/svc-db-redis/templates/service.yml.j2 @@ -13,7 +13,7 @@ - "no" - --save - "" - - --maxmemory {{ lookup('applications') | redis_maxmemory_mb(application_id, 0.8, RESOURCE_MEM_LIMIT | int ) }}mb + - --maxmemory {{ lookup('service_memory_mb', application_id, 'redis', 0.8) }}mb - --maxmemory-policy - "allkeys-lru" # nocheck: container-healthcheck-literal probes every second, so the SPOT's 5s start cadence would report healthy later than this block already does diff --git a/roles/web-app-confluence/vars/main.yml b/roles/web-app-confluence/vars/main.yml index 2ce263f096..180441ce0b 100644 --- a/roles/web-app-confluence/vars/main.yml +++ b/roles/web-app-confluence/vars/main.yml @@ -25,7 +25,7 @@ CONFLUENCE_VERSION: "{{ lookup('config', application_id, 'services.' ~ entity_na CONFLUENCE_IMAGE: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.image') }}" CONFLUENCE_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.name') }}" -CONFLUENCE_JVM_MAX: "{{ lookup('applications') | jvm_max_mb(application_id) }}m" -CONFLUENCE_JVM_MIN: "{{ lookup('applications') | jvm_min_mb(application_id) }}m" +CONFLUENCE_JVM_MAX: "{{ lookup('service_memory_mb', application_id, entity_name, 0.7) }}m" +CONFLUENCE_JVM_MIN: "{{ lookup('service_memory_mb', application_id, entity_name, 0.35) }}m" CONFLUENCE_TRUST_STORE_ENABLED: "{{ lookup('config', application_id, 'services.confluence.truststore_enabled') }}" diff --git a/roles/web-app-jira/vars/main.yml b/roles/web-app-jira/vars/main.yml index 1e5fbc3de4..820d9117fb 100644 --- a/roles/web-app-jira/vars/main.yml +++ b/roles/web-app-jira/vars/main.yml @@ -25,5 +25,5 @@ JIRA_VERSION: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ ' JIRA_IMAGE: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.image') }}" JIRA_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.name') }}" -JIRA_JVM_MAX: "{{ lookup('applications') | jvm_max_mb(application_id) }}m" -JIRA_JVM_MIN: "{{ lookup('applications') | jvm_min_mb(application_id) }}m" +JIRA_JVM_MAX: "{{ lookup('service_memory_mb', application_id, entity_name, 0.7) }}m" +JIRA_JVM_MIN: "{{ lookup('service_memory_mb', application_id, entity_name, 0.35) }}m" diff --git a/tests/unit/python/plugins/filter/test_memory_filters.py b/tests/unit/python/plugins/filter/test_memory_filters.py deleted file mode 100644 index 002eecd3fd..0000000000 --- a/tests/unit/python/plugins/filter/test_memory_filters.py +++ /dev/null @@ -1,234 +0,0 @@ -import importlib -import unittest -from unittest.mock import patch - -memory_filters = importlib.import_module("plugins.filter.memory_filters") - - -class TestMemoryFilters(unittest.TestCase): - def setUp(self): - self.apps = {"whatever": True} - self.app_id = "web-app-confluence" # entity_name will be mocked - - # ----------------------------- - # Helpers - # ----------------------------- - def _with_conf(self, mem_limit: str, mem_res: str): - """ - Patch get/get_entity_name so that mem_limit and mem_reservation - can be controlled in tests. - """ - patches = [ - patch( - "plugins.filter.memory_filters.get_entity_name", - return_value="confluence", - ), - patch( - "plugins.filter.memory_filters.get", - side_effect=lambda apps, app_id, key, required=True, **kwargs: ( - mem_limit - if key.endswith(".mem_limit") - else mem_res - if key.endswith(".mem_reservation") - else None - ), - ), - ] - mocks = [p.start() for p in patches] - self.addCleanup(lambda: [p.stop() for p in patches]) - return mocks - - # ----------------------------- - # Tests: jvm_max_mb / jvm_min_mb sizing - # ----------------------------- - def test_sizing_8g_limit_6g_reservation(self): - self._with_conf("8g", "6g") - xmx = memory_filters.jvm_max_mb(self.apps, self.app_id) - xms = memory_filters.jvm_min_mb(self.apps, self.app_id) - self.assertEqual(xmx, 5734) - self.assertEqual(xms, 2867) - - def test_sizing_6g_limit_4g_reservation(self): - self._with_conf("6g", "4g") - xmx = memory_filters.jvm_max_mb(self.apps, self.app_id) - xms = memory_filters.jvm_min_mb(self.apps, self.app_id) - self.assertEqual(xmx, 4300) - self.assertEqual(xms, 2150) - - def test_sizing_16g_limit_12g_reservation_cap_12288(self): - self._with_conf("16g", "12g") - xmx = memory_filters.jvm_max_mb(self.apps, self.app_id) - xms = memory_filters.jvm_min_mb(self.apps, self.app_id) - self.assertEqual(xmx, 11468) - self.assertEqual(xms, 5734) - - def test_floor_small_limit_results_in_min_1024(self): - self._with_conf("1g", "512m") - xmx = memory_filters.jvm_max_mb(self.apps, self.app_id) - self.assertEqual(xmx, 1024) - - def test_floor_small_reservation_results_in_min_512(self): - self._with_conf("4g", "128m") - xms = memory_filters.jvm_min_mb(self.apps, self.app_id) - self.assertEqual(xms, 512) - - # ----------------------------- - # Tests: JVM failure cases / validation - # ----------------------------- - def test_invalid_unit_raises(self): - with ( - patch( - "plugins.filter.memory_filters.get_entity_name", - return_value="confluence", - ), - patch( - "plugins.filter.memory_filters.get", - side_effect=lambda apps, app_id, key, required=True, **kwargs: ( - "8Q" if key.endswith(".mem_limit") else "4g" - ), - ), - self.assertRaises(memory_filters.AnsibleFilterError), - ): - memory_filters.jvm_max_mb(self.apps, self.app_id) - - def test_zero_limit_raises(self): - with ( - patch( - "plugins.filter.memory_filters.get_entity_name", - return_value="confluence", - ), - patch( - "plugins.filter.memory_filters.get", - side_effect=lambda apps, app_id, key, required=True, **kwargs: ( - "0" if key.endswith(".mem_limit") else "4g" - ), - ), - self.assertRaises(memory_filters.AnsibleFilterError), - ): - memory_filters.jvm_max_mb(self.apps, self.app_id) - - def test_zero_reservation_raises(self): - with ( - patch( - "plugins.filter.memory_filters.get_entity_name", - return_value="confluence", - ), - patch( - "plugins.filter.memory_filters.get", - side_effect=lambda apps, app_id, key, required=True, **kwargs: ( - "8g" if key.endswith(".mem_limit") else "0" - ), - ), - self.assertRaises(memory_filters.AnsibleFilterError), - ): - memory_filters.jvm_min_mb(self.apps, self.app_id) - - def test_entity_name_is_derived_not_passed(self): - """ - Ensure get_entity_name() is called internally and the app_id is not - passed around manually from the template. - """ - with ( - patch( - "plugins.filter.memory_filters.get_entity_name", - return_value="confluence", - ) as mock_entity, - patch( - "plugins.filter.memory_filters.get", - side_effect=lambda apps, app_id, key, required=True, **kwargs: ( - "8g" if key.endswith(".mem_limit") else "6g" - ), - ), - ): - xmx = memory_filters.jvm_max_mb(self.apps, self.app_id) - xms = memory_filters.jvm_min_mb(self.apps, self.app_id) - self.assertGreater(xmx, 0) - self.assertGreater(xms, 0) - self.assertEqual(mock_entity.call_count, 3) - for call in mock_entity.call_args_list: - self.assertEqual(call.args[0], self.app_id) - - # ----------------------------- - # Tests: redis_maxmemory_mb - # ----------------------------- - def test_redis_maxmemory_default_factor_uses_80_percent_of_limit(self): - self._with_conf("1g", "512m") - maxmem = memory_filters.redis_maxmemory_mb(self.apps, self.app_id) - self.assertEqual(maxmem, 819) - - def test_redis_maxmemory_custom_factor_and_min_mb(self): - self._with_conf("1g", "512m") - maxmem = memory_filters.redis_maxmemory_mb( - self.apps, - self.app_id, - factor=0.5, - min_mb=128, - ) - self.assertEqual(maxmem, 512) - - def test_redis_maxmemory_honors_minimum_floor(self): - self._with_conf("32m", "16m") - maxmem = memory_filters.redis_maxmemory_mb(self.apps, self.app_id) - self.assertEqual(maxmem, 64) - - def test_redis_maxmemory_zero_limit_raises(self): - self._with_conf("0", "512m") - with self.assertRaises(memory_filters.AnsibleFilterError): - memory_filters.redis_maxmemory_mb(self.apps, self.app_id) - - def test_redis_maxmemory_invalid_unit_raises(self): - self._with_conf("8Q", "512m") - with self.assertRaises(memory_filters.AnsibleFilterError): - memory_filters.redis_maxmemory_mb(self.apps, self.app_id) - - def test_redis_maxmemory_does_not_call_get_entity_name(self): - """ - Ensure redis_maxmemory_mb does NOT rely on entity name resolution - (it should always use the hard-coded 'redis' service name). - """ - patches = [ - patch("plugins.filter.memory_filters.get_entity_name"), - patch( - "plugins.filter.memory_filters.get", - side_effect=lambda apps, app_id, key, required=True, **kwargs: ( - "4g" if key.endswith(".mem_limit") else "2g" - ), - ), - ] - mocks = [p.start() for p in patches] - self.addCleanup(lambda: [p.stop() for p in patches]) - - entity_mock = mocks[0] - - maxmem = memory_filters.redis_maxmemory_mb(self.apps, self.app_id) - self.assertEqual(maxmem, 3276) - entity_mock.assert_not_called() - - def test_redis_maxmemory_uses_default_when_mem_limit_missing(self): - """ - When services.redis.mem_limit is not configured, the filter - should fall back to its internal default (256m). - """ - - def fake_get(apps, app_id, key, required=True, **kwargs): - if key.endswith(".mem_limit"): - return kwargs.get("default") - return None - - with ( - patch( - "plugins.filter.memory_filters.get", - side_effect=fake_get, - ), - patch( - "plugins.filter.memory_filters.get_entity_name", - return_value="confluence", - ), - ): - maxmem = memory_filters.redis_maxmemory_mb(self.apps, self.app_id) - - self.assertEqual(maxmem, 204) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/python/plugins/lookup/test_service_memory_mb.py b/tests/unit/python/plugins/lookup/test_service_memory_mb.py new file mode 100644 index 0000000000..d8277c6c11 --- /dev/null +++ b/tests/unit/python/plugins/lookup/test_service_memory_mb.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + +from ansible.errors import AnsibleError + +from plugins.lookup.service_memory_mb import LookupModule, docker_bytes, share_mb + +_APP = "web-app-demo" + + +class TestDockerBytes(unittest.TestCase): + def test_suffixes_are_binary(self) -> None: + self.assertEqual(docker_bytes("512m"), 512 * 1024**2) + self.assertEqual(docker_bytes("2g"), 2 * 1024**3) + self.assertEqual(docker_bytes("1.5g"), 1536 * 1024**2) + self.assertEqual(docker_bytes("64K"), 64 * 1024) + self.assertEqual(docker_bytes("1gb"), 1024**3) + + def test_a_bare_number_is_bytes(self) -> None: + self.assertEqual(docker_bytes(1048576), 1024**2) + self.assertEqual(docker_bytes("2048"), 2048) + + def test_an_unreadable_size_raises(self) -> None: + for size in ("", "lots", "2x", "-1g", None): + with self.subTest(size=size), self.assertRaises(AnsibleError): + docker_bytes(size) + + +class TestShareMb(unittest.TestCase): + def test_share_of_the_limit_in_whole_mebibytes(self) -> None: + cases = ( + ("2g", 0.5, 1024), + ("4g", 0.5, 2048), + ("8g", 0.7, 5734), + ("8g", 0.35, 2867), + ("6g", 0.7, 4300), + ("512m", 0.8, 409), + ("256m", 0.75, 192), + ("1.5g", 0.5, 768), + ("1g", 1, 1024), + ) + for mem_limit, share, expected in cases: + with self.subTest(mem_limit=mem_limit, share=share): + self.assertEqual(share_mb(mem_limit, share), expected) + + def test_a_share_outside_the_limit_raises(self) -> None: + for share in (0, -0.5, 1.01, "half", None): + with self.subTest(share=share), self.assertRaises(AnsibleError): + share_mb("2g", share) + + def test_a_result_below_one_mebibyte_raises(self) -> None: + with self.assertRaises(AnsibleError): + share_mb("1m", 0.5) + + +class TestLookupResolvesTheContainerLimit(unittest.TestCase): + def _run(self, services: dict, variables: dict, terms: list) -> int: + templar = MagicMock() + templar.available_variables = variables + templar.template.side_effect = lambda value: {"{{ HOST }}": "1.5g"}.get( + value, value + ) + applications = MagicMock() + applications.run.return_value = [{_APP: {"services": services}}] + lookup = LookupModule(loader=None, templar=templar) + with patch( + "plugins.lookup.service_memory_mb.lookup_loader.get", + return_value=applications, + ): + return lookup.run(terms, variables=variables)[0] + + def test_the_named_service_wins(self) -> None: + services = {"search": {"mem_limit": "2g"}, "demo": {"mem_limit": "8g"}} + + self.assertEqual(self._run(services, {}, [_APP, "search", 0.5]), 1024) + + def test_the_primary_entity_backs_an_undeclared_service(self) -> None: + services = {"search": {}, "demo": {"mem_limit": "8g"}} + + self.assertEqual(self._run(services, {}, [_APP, "search", 0.5]), 4096) + + def test_the_host_default_backs_an_undeclared_role(self) -> None: + variables = {"RESOURCE_MEM_LIMIT": "{{ HOST }}"} + + self.assertEqual( + self._run({"search": {}}, variables, [_APP, "search", 0.5]), 768 + ) + + def test_no_limit_anywhere_raises(self) -> None: + with self.assertRaises(AnsibleError): + self._run({"search": {}}, {}, [_APP, "search", 0.5]) + + def test_the_term_count_is_checked(self) -> None: + with self.assertRaises(AnsibleError): + self._run({"search": {"mem_limit": "2g"}}, {}, [_APP, "search"]) + + +if __name__ == "__main__": + unittest.main()