From e203d6f7ab60034a8a2ed3bf67fe7389b62bcf65 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 6 Oct 2026 23:05:26 +0200 Subject: [PATCH] feat(plugins): size memory parameters from the container limit with one lookup lookup('service_memory_mb', application_id, service_name, share) returns a share of a service's mem_limit in whole MiB. It resolves the limit the way sys-svc-container resolves the container's own: the named service, then the role's primary entity, then RESOURCE_MEM_LIMIT. It replaces the three filters of memory_filters, each with its own unit parser and its own idea of the limit: Redis maxmemory at 0.8 of the limit, and the Confluence and Jira heap at 0.7 and 0.35. The central Redis (409 MiB), Confluence (5734/2867) and Jira (4300/2150) keep their values. A Redis sidecar without a mem_limit of its own does not: the filter fell back to a fixed 256m, which gave 204 MiB, while the lookup takes the limit that container really runs with, the primary entity's. That moves 25 roles to between 409 and 3276 MiB. The heap clamps of the old filters are gone: the 1024 MiB floor, limit minus 1024, the 12288 cap and Xms bounded by mem_reservation. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/filter/memory_filters.py | 184 -------------- plugins/lookup/service_memory_mb.py | 106 ++++++++ roles/svc-db-redis/templates/compose.yml.j2 | 2 +- roles/svc-db-redis/templates/service.yml.j2 | 2 +- roles/web-app-confluence/vars/main.yml | 4 +- roles/web-app-jira/vars/main.yml | 4 +- .../plugins/filter/test_memory_filters.py | 234 ------------------ .../plugins/lookup/test_service_memory_mb.py | 101 ++++++++ 8 files changed, 213 insertions(+), 424 deletions(-) delete mode 100644 plugins/filter/memory_filters.py create mode 100644 plugins/lookup/service_memory_mb.py delete mode 100644 tests/unit/python/plugins/filter/test_memory_filters.py create mode 100644 tests/unit/python/plugins/lookup/test_service_memory_mb.py 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()