Files
computer-playbook/tests/unit/python/roles/sys-svc-container/test_sandbox_placement.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

128 lines
4.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"
SANDBOX_LABEL = "kata-capable"
def _ansible_bool(value):
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, str):
return value.strip().lower() in {"y", "yes", "true", "on", "1"}
return bool(value)
BASE = {
"compose_mode": "swarm",
"application_id": "web-app-hermes",
"service_name": "hermes",
"RUNTIME": "local",
"SANDBOX_NODE_LABEL": SANDBOX_LABEL,
"DOCKER_RESTART_POLICY": "unless-stopped",
"RESOURCE_CPUS": "1",
"RESOURCE_HOST_CPUS": "8",
"RESOURCE_MEM_LIMIT": "1g",
"RESOURCE_MEM_RESERVATION": "256m",
"RESOURCE_PIDS_LIMIT": "512",
}
class TestSandboxPlacement(unittest.TestCase):
def _render(
self, kata_enabled, manager_placed=False, sandbox_tier=True, **overrides
):
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, *_a, **_k: ""
env.filters["swarm_restart_condition"] = lambda value: value
def _lookup(name, *args, **kwargs):
if name == "applications":
return {}
if name == "compose_replicas":
return "replicas: 3"
if name == "roles_with_placement":
return ["web-app-hermes"] if manager_placed else []
if name == "config":
return kata_enabled
raise AssertionError(f"unexpected lookup: {name}")
env.globals["lookup"] = _lookup
groups = {"svc-virt-kata": ["wrk-02"]} if sandbox_tier else {}
return env.get_template("deploy.yml.j2").render(
{**BASE, "groups": groups, **overrides}
)
def _constraints(self, rendered):
lines = [line.strip() for line in rendered.splitlines()]
return [line[2:] for line in lines if line.startswith("- node.")]
def test_sandbox_consumer_is_pinned_to_a_sandbox_node(self):
constraints = self._constraints(self._render(kata_enabled=True))
self.assertIn(f"node.labels.{SANDBOX_LABEL} == true", constraints)
def test_every_other_service_is_kept_off_the_sandbox_nodes(self):
constraints = self._constraints(self._render(kata_enabled=False))
self.assertIn(f"node.labels.{SANDBOX_LABEL} != true", constraints)
def test_the_two_directions_are_mutually_exclusive(self):
for enabled in (True, False):
with self.subTest(kata_enabled=enabled):
constraints = self._constraints(self._render(kata_enabled=enabled))
self.assertEqual(len([c for c in constraints if SANDBOX_LABEL in c]), 1)
def test_a_cluster_without_a_sandbox_tier_gets_no_sandbox_constraint(self):
for enabled in (True, False):
with self.subTest(kata_enabled=enabled):
constraints = self._constraints(
self._render(kata_enabled=enabled, sandbox_tier=False)
)
self.assertEqual([c for c in constraints if SANDBOX_LABEL in c], [])
def test_manager_placement_composes_with_the_sandbox_constraint(self):
constraints = self._constraints(
self._render(kata_enabled=False, manager_placed=True)
)
self.assertIn("node.role == manager", constraints)
self.assertIn(f"node.labels.{SANDBOX_LABEL} != true", constraints)
def test_manager_placement_survives_without_a_sandbox_tier(self):
constraints = self._constraints(
self._render(kata_enabled=False, manager_placed=True, sandbox_tier=False)
)
self.assertEqual(constraints, ["node.role == manager"])
def test_compose_mode_emits_no_deploy_block(self):
rendered = self._render(kata_enabled=True, compose_mode="compose")
self.assertEqual(rendered.strip(), "")
def test_a_failed_task_does_not_latch_the_rollout(self):
"""Swarm's default pauses forever on the first task that fails.
With no failure_action, swarmkit applies pause at a max_failure_ratio
of zero, so one transient failure stops the rollout for good and no
step in the deploy resumes it. The restart policy replaces the task and
the service ends up healthy, while the update status stays latched and
the convergence check reports the deploy as failed.
"""
rendered = self._render(kata_enabled=False)
self.assertIn("failure_action: continue", rendered)
if __name__ == "__main__":
unittest.main()