Files
computer-playbook/tests/unit/python/roles/sys-svc-container/test_gpu_runtime.py
T
kevinveenbirkenbachandClaude Opus 5 f7b6f0d84e feat(resources): let cpus name a percentage of the host
A container cap had to be an absolute number, and docker refuses one above
the host's core count, so "use this machine" could only be written as a
number that breaks on a smaller machine. cpus now also takes a percentage,
read against the host's raw count: 100% is every CPU and lands exactly on the
ceiling docker accepts, 50% is half of it.

The share is read against that raw count rather than against the fair-share
default, because the two CPUs the resource lookup reserves exist to bound
what a service gets when it declares nothing. A service writing a percentage
overrides that default on purpose, and subtracting the reserve a second time
would count it twice and leave no way to name the whole machine.

A bare cpus: 0 is refused from here on. Docker reads a zero as uncapped
rather than as none, so the value most likely to be typed by mistake was also
the one that removed the limit; the error names the fix. No role declared
one: all 315 cpus values in the tree are plain numbers, the smallest 0.01.

Two lookups carry the resolution so that no caller repeats it or has to know
what a percentage is read against. lookup('cpus', app, service) replaces six
copies of lookup('config', ...) piped through the resolver; those six size
worker pools and thread counts from their own cpus, and left alone a
percentage would have given libretranslate a single thread and handed the
agent broker an uncapped container. lookup('webserver_resource', key)
replaces the two blocks that took the smaller of the proxy's value and the
app's, which were the same eight lines written twice for cpus and for
pids_limit.

resolve_cpus is no longer registered as a jinja filter. Nothing referenced it
from a template once the lookups existed, and keeping it would have left two
public ways to resolve one value.

Measured: 96 tests across the filter, both lookups and the container render
templates, including that a percentage resolves before the smaller of the two
is chosen, and that pids_limit stays an integer because nginx renders it
straight into worker_connections.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 10:14:43 +02:00

87 lines
2.8 KiB
Python

import unittest
from pathlib import Path
from jinja2 import (
Environment,
FileSystemLoader,
StrictUndefined,
select_autoescape,
)
from utils import PROJECT_ROOT
TEMPLATES = Path(PROJECT_ROOT) / "roles" / "sys-svc-container" / "templates"
BASE = {
"application_id": "web-svc-libretranslate",
"service_name": "libretranslate",
"RESOURCE_CPUS": "1",
"RESOURCE_HOST_CPUS": "8",
"RESOURCE_MEM_LIMIT": "1g",
"RESOURCE_MEM_RESERVATION": "256m",
"RESOURCE_PIDS_LIMIT": "512",
}
def _ansible_bool(value):
if isinstance(value, str):
return value.strip().lower() in {"y", "yes", "true", "on", "1"}
return bool(value)
class TestGpuRuntimeRegistration(unittest.TestCase):
def _render(self, *, wants_gpu, host_has_gpu):
env = Environment(
loader=FileSystemLoader(str(TEMPLATES)),
trim_blocks=True,
lstrip_blocks=False,
undefined=StrictUndefined,
autoescape=select_autoescape(),
)
env.filters["bool"] = _ansible_bool
env.filters["resource_filter"] = lambda _apps, _id, key, _svc, default, **_k: (
wants_gpu if key == "gpu" else default
)
env.globals["lookup"] = lambda name, *a, **k: {}
device = {"stat": {"exists": host_has_gpu}}
return env.get_template("resource.yml.j2").render(
{**BASE, "sys_svc_container_nvidia_device": device}
)
def test_a_gpu_service_on_a_gpu_host_gets_the_nvidia_runtime(self):
self.assertIn(
"runtime: nvidia",
self._render(wants_gpu=True, host_has_gpu=True),
)
def test_a_gpu_service_on_a_host_without_a_card_gets_no_runtime(self):
self.assertNotIn("runtime:", self._render(wants_gpu=True, host_has_gpu=False))
def test_a_plain_service_never_gets_the_nvidia_runtime(self):
for host_has_gpu in (True, False):
with self.subTest(host_has_gpu=host_has_gpu):
self.assertNotIn(
"runtime:",
self._render(wants_gpu=False, host_has_gpu=host_has_gpu),
)
def test_a_missing_device_fact_is_treated_as_no_gpu(self):
env = Environment(
loader=FileSystemLoader(str(TEMPLATES)),
trim_blocks=True,
lstrip_blocks=False,
undefined=StrictUndefined,
autoescape=select_autoescape(),
)
env.filters["bool"] = _ansible_bool
env.filters["resource_filter"] = lambda _apps, _id, key, _svc, default, **_k: (
True if key == "gpu" else default
)
env.globals["lookup"] = lambda name, *a, **k: {}
self.assertNotIn("runtime:", env.get_template("resource.yml.j2").render(BASE))
if __name__ == "__main__":
unittest.main()