mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
A Redis sidecar whose consumer declares no services.redis.mem_limit
runs in a container that inherits the consumer's primary entity limit.
Since the lookup replaced the old filter, maxmemory followed that limit
and rose from 204 MiB to between 819 and 3276 MiB in 17 roles.
lookup('service_memory_mb', ..., provider=<role>) caps the result at
the same share of the provider's own limit when the application does
not declare one for that service. A declared limit is not capped, and a
container smaller than the provider keeps its own share. The Redis
sidecar passes provider='svc-db-redis': those 17 roles get 409 MiB, the
seven with a 256 MiB container stay at 204.
The lookup now rejects unknown options and a provider that is no role
or declares no limit for the service, so a misspelled option fails
instead of silently lifting the cap.
The memcached sidecar's literal -m 64 takes the same lookup and keeps
its value.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
150 lines
5.9 KiB
Python
150 lines
5.9 KiB
Python
"""Derive a memory parameter in MiB from the memory limit of a service's container.
|
|
|
|
-Xmx{{ lookup('service_memory_mb', application_id, 'search', 0.25) }}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.<service>.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.
|
|
|
|
A sidecar that a provider role ships into other stacks passes ``provider``:
|
|
|
|
--maxmemory {{ lookup('service_memory_mb', application_id, 'redis', 0.8, provider='svc-db-redis') }}mb
|
|
|
|
An application that declares no ``mem_limit`` for that service lets its container
|
|
inherit the primary entity's, which is sized for the application and not for the
|
|
engine beside it. The result is then capped at the same share of the provider's
|
|
own limit. An application that declares the limit itself is not capped.
|
|
|
|
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 ``-Xmx<n>m``, PHP ``<n>M``,
|
|
``memcached -m``, Redis ``<n>mb`` and PostgreSQL ``<n>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
|
|
from utils.roles.applications.config import (
|
|
AppConfigKeyError,
|
|
ConfigEntryNotSetError,
|
|
get,
|
|
)
|
|
|
|
_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[, provider=role])"""
|
|
|
|
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
|
|
unknown = sorted(set(kwargs) - {"provider"})
|
|
if unknown:
|
|
raise AnsibleError(
|
|
f"lookup('service_memory_mb'): unknown option(s) {unknown}; "
|
|
"the only option is provider=<role>"
|
|
)
|
|
|
|
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])
|
|
megabytes = share_mb(mem_limit, share)
|
|
|
|
if "provider" in kwargs:
|
|
if not kwargs["provider"] or not isinstance(kwargs["provider"], str):
|
|
raise AnsibleError(
|
|
"lookup('service_memory_mb'): provider must name a role"
|
|
)
|
|
own = f"services.{service_name}.mem_limit"
|
|
try:
|
|
ceiling = get(applications, kwargs["provider"], own, False, _UNDECLARED)
|
|
inherited = (
|
|
get(applications, application_id, own, False, _UNDECLARED)
|
|
is _UNDECLARED
|
|
)
|
|
except (AppConfigKeyError, ConfigEntryNotSetError) as exc:
|
|
raise AnsibleError(f"lookup('service_memory_mb'): {exc}") from exc
|
|
if ceiling is _UNDECLARED:
|
|
raise AnsibleError(
|
|
f"lookup('service_memory_mb'): provider {kwargs['provider']!r} "
|
|
f"declares no mem_limit for '{service_name}'"
|
|
)
|
|
if inherited:
|
|
megabytes = min(megabytes, share_mb(ceiling, share))
|
|
return [megabytes]
|