mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 04:06:42 +00:00
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>
128 lines
4.8 KiB
Python
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()
|