4"""The schema, arithmetic and derivations behind ``infra/fleet.yml``.
6The CLI and declaration gate both import this model, keeping provisioning,
7capacity arithmetic, role variables, inventory and validation behind one
8definition. Machine reachability is isolated in :mod:`fleet_reach`; the native
9non-capacity HIL listener is isolated in :mod:`fleet_hil`.
12from __future__
import annotations
17from dataclasses
import dataclass
18from pathlib
import Path
23sys.path.insert(0, str(Path(__file__).resolve().parent))
26import fleet_reach
as fr
27import fleet_runner_model
as frm
31REPO_ROOT = Path(__file__).resolve().parents[2]
32FLEET_FILE = REPO_ROOT /
"infra" /
"fleet.yml"
33ANSIBLE_DIR = REPO_ROOT /
"infra" /
"ansible"
36def _inventory_path() -> Path:
37 """Select the service's writable inventory without moving source authority."""
38 override = os.environ.get(
"RA8_FLEET_INVENTORY")
40 return ANSIBLE_DIR /
"inventory" /
"hosts.ini"
42 if not path.is_absolute():
43 message =
"RA8_FLEET_INVENTORY must be an absolute path"
44 raise ValueError(message)
48INVENTORY = _inventory_path()
54HOST_VARS_DIR = ANSIBLE_DIR /
"inventory" /
"host_vars"
57def validate_runtime_inventory(state_dir: Path) ->
None:
58 """Bind installed inventory writes beside the immutable host-variable source."""
59 expected = state_dir /
"inventory" /
"hosts.ini"
60 if expected != INVENTORY:
61 message =
"installed reconciliation inventory is outside its private state directory"
62 raise ValueError(message)
63 host_vars = expected.parent /
"host_vars"
64 if not host_vars.is_symlink()
or host_vars.readlink() != HOST_VARS_DIR:
65 message =
"runtime inventory host variables are not bound to the immutable source"
66 raise ValueError(message)
71WEEKDAYS = (
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun")
78@dataclass(frozen=True)
80 """One provisioning play: a playbook, the group it targets, and its roles.
83 playbook: File name under ``infra/ansible/playbooks/``.
84 group: Inventory group the play's ``hosts:`` selects.
85 roles: Roles the play applies, in order, for ``infra-list``.
86 removable: Whether the roles genuinely implement a teardown path.
87 Claiming one that does not exist is worse than admitting there is
88 none, so this is only true where ``state=absent`` is implemented.
89 summary: One line for the listing.
94 roles: tuple[str, ...]
99PLAYS: dict[str, Play] = {
101 playbook=
"dev-box.yml",
105 summary=
"the pinned host toolchain",
108 playbook=
"k3s-node.yml",
110 roles=(
"k3s_node",
"openbao"),
112 summary=
"k3s + helm + the vault",
115 playbook=
"ci-runner.yml",
117 roles=(
"ci_runner",),
119 summary=
"the ARC autoscaling runner pool",
121 "ci-runner-docker": Play(
122 playbook=
"ci-runner-docker.yml",
123 group=
"ci_runners_docker",
124 roles=(
"ci_runner_docker",
"dev_slice",
"fleet_capacity"),
126 summary=
"long-lived runner containers on a Docker host",
129 playbook=
"wsl-ci-host.yml",
130 group=
"wsl_ci_hosts",
131 roles=(
"wsl_ci_host",
"ci_runner_docker",
"dev_slice",
"fleet_capacity"),
133 summary=
"a Windows machine's WSL2 distro, then the runners into it",
136 playbook=
"hil-bench.yml",
138 roles=(
"hil_bench",
"c6_toolchain",
"ad2_tools"),
140 summary=
"the HIL bench Pi, ESP32-C6 and AD2",
151NO_DRAIN_TAGS = frozenset({
"capacity",
"dev-slice"})
158SYSTEMD_DEFAULT_CPU_WEIGHT = 100
161CGROUP_MAX_CPU_WEIGHT = 10000
179DEV_SLICE_UNIT =
"ra8dev.slice"
182class FleetError(Exception):
183 """A fleet declaration could not be read or does not describe a real fleet."""
186def load(path: Path = FLEET_FILE) -> dict[str, Any]:
187 """Read and structurally check ``infra/fleet.yml``.
190 path: Declaration to read. Overridden only by the selftest.
193 The parsed mapping, with ``sizing``, ``runner_image`` and ``hosts``
197 FleetError: The file is missing, is not a mapping, or lacks either of
198 the two top-level keys everything else derives from.
200 if not path.is_file():
201 msg = f
"no fleet declaration at {path}"
202 raise FleetError(msg)
203 data = yaml.safe_load(path.read_text(encoding=
"utf-8"))
204 if not isinstance(data, dict):
205 msg = f
"{path} does not parse to a mapping"
206 raise FleetError(msg)
207 for key
in (
"sizing",
"runner_image",
"hosts"):
208 if not isinstance(data.get(key), dict):
209 msg = f
"{path} has no '{key}:' mapping"
210 raise FleetError(msg)
214def recommended_instances(sizing: dict[str, Any], budget: dict[str, Any]) -> int:
215 """Instance count the sizing formula gives for a budget.
217 ``min(threads / build_parallelism, memory_gb / memory_per_instance_gb)``.
218 Both divisors are measured properties of this tree, documented at the top
219 of ``infra/fleet.yml``: a job cannot use more CPUs than the workflows'
220 pinned build parallelism, and clang-tidy has been OOM-killed below the
224 sizing: The declaration's ``sizing:`` block.
225 budget: One host's ``budget:`` block.
228 The recommended count, never below zero.
230 by_cpu = int(budget[
"threads"]) // int(sizing[
"build_parallelism"])
231 by_mem = int(budget[
"memory_gb"]) // int(sizing[
"memory_per_instance_gb"])
232 return max(0,
min(by_cpu, by_mem))
235def instance_names(name: str, host: dict[str, Any]) -> list[str]:
236 """Runner registration names this host's instances will carry on GitHub.
238 Mirrors the ``ci_runner_docker`` role exactly: a single instance keeps the
239 unsuffixed base name, and above one every instance is ``<base>-<i>``. The
240 difference matters because those are the names in
241 ``gh api .../actions/runners``, and moving between the two forms renames a
245 name: Fleet host name, the default base.
246 host: That host's declaration.
249 One name per declared instance, in instance order. Empty for an ARC
250 host: its runner names are generated per ephemeral pod by the
251 controller, so there is no stable set to predict.
253 return frm.instance_names(name, host)
256def container_names(host: dict[str, Any]) -> list[str]:
257 """Docker container names for this host's instances, in instance order.
260 host: One host's declaration.
263 Container names the capacity script drains, empty for a non-container
266 return frm.container_names(host)
269def remote_shell(host: dict[str, Any]) -> str:
270 """The remote command that reads a shell script on stdin and runs it.
272 A WSL host has no SSH daemon of its own, so the play and every capacity
273 command reach the distro through the Windows side's ssh and ``wsl -e``.
274 Feeding the script on stdin rather than quoting it into the command line
275 keeps it clear of both the Windows shell's parsing and the distro's.
278 host: One host's declaration.
281 A remote command string ending in ``bash -s``.
283 return frm.remote_shell(host)
286def docker_command(host: dict[str, Any]) -> str:
287 """How the capacity script must invoke Docker on this host.
290 host: One host's declaration.
293 ``docker`` where the connecting user owns the socket, ``sudo docker``
296 return frm.docker_command(host)
299def _runner_vars(name: str, host: dict[str, Any]) -> dict[str, Any]:
300 """Ansible variables carrying this host's declared runner capacity.
303 name: Fleet host name.
304 host: That host's declaration.
307 The role variables for the host's class, empty for a non-runner class.
309 if not CLASSES[host[
"class"]].capacity_runner:
311 run = host[
"runners"]
312 if host[
"class"] ==
"arc_k8s":
314 "ci_runner_max": int(run[
"instances"]),
315 "ci_runner_cpu_limit": str(run[
"cpus"]),
316 "ci_runner_mem_limit": f
"{run['memory_gb']}Gi",
317 "ci_runner_cpu_request": str(run[
"cpu_request"]),
318 "ci_runner_mem_request": f
"{run['memory_request_gb']}Gi",
319 "ci_runner_scale_set_name": run[
"labels"][0],
321 memory = f
"{run['memory_gb']}g"
322 out: dict[str, Any] = {
323 "ci_runner_docker_name": run.get(
"name", name),
324 "ci_runner_docker_instances": int(run[
"instances"]),
325 "ci_runner_docker_cpus": str(run[
"cpus"]),
326 "ci_runner_docker_memory": memory,
330 "ci_runner_docker_memswap": memory,
331 "ci_runner_docker_pin_cpus": bool(run.get(
"pin_cpus",
False)),
332 "ci_runner_docker_labels":
",".join(run[
"labels"]),
334 if host[
"class"] ==
"docker_wsl":
335 out.update(_wsl_vars(host))
339def _runner_image_vars(data: dict[str, Any], host: dict[str, Any]) -> dict[str, Any]:
340 """Map the one declared runner artifact onto its producer and consumers.
343 data: The complete fleet declaration.
344 host: One host's declaration.
347 Image variables for the ARC producer or a Docker consumer, empty for a
348 machine that neither builds nor runs the shared image.
350 image = data[
"runner_image"]
351 if host[
"class"] ==
"arc_k8s":
353 "ci_runner_image": image[
"image"],
354 "ci_runner_image_archive": image[
"archive"],
356 if CLASSES[host[
"class"]].capacity_kind ==
"docker":
358 "ci_runner_docker_image": image[
"image"],
359 "ci_runner_docker_image_source_host": image[
"source_host"],
360 "ci_runner_docker_image_source_archive": image[
"archive"],
361 "ci_runner_docker_image_source_ssh": fr.ssh_target(data, image[
"source_host"]),
366def _wsl_vars(host: dict[str, Any]) -> dict[str, Any]:
367 """The WSL2 VM caps, which are this host's CI budget stated to Windows.
370 host: One ``docker_wsl`` host's declaration.
373 The ``wsl_ci_host`` role variables written into ``.wslconfig``.
375 budget = host[
"budget"]
376 connect = host[
"connect"]
378 "wsl_ci_host_windows_user": connect[
"windows_user"],
379 "wsl_ci_host_distro": connect[
"distro"],
380 "wsl_ci_host_processors": int(budget[
"threads"]),
381 "wsl_ci_host_memory": f
"{budget['memory_gb']}GB",
382 "wsl_ci_host_swap": f
"{budget['swap_gb']}GB",
386def _dev_slice_vars(host: dict[str, Any]) -> dict[str, Any]:
387 """Ansible variables for the low-priority dev slice, if one is declared.
389 A runner host is a CI host first. The slice it lends to agents is therefore
390 declared as a CPU *weight* (it consumes whatever CI is not using and yields
391 the moment a job arrives) and a HARD memory cap (memory does not yield, so
392 it has to be taken out of CI's reservation up front).
394 ``dev_slice_enabled`` is false for a host with no block, so the role
395 REMOVES a slice a previous declaration installed. Deleting a block must
396 undo it, not orphan a cgroup nobody can account for.
399 host: One host's declaration.
402 The ``dev_slice`` role variables, empty for a class that carries none.
404 if CLASSES[host[
"class"]].capacity_kind !=
"docker":
406 slice_ = host.get(
"dev_slice")
or {}
407 out: dict[str, Any] = {
"dev_slice_enabled": bool(slice_)}
412 "dev_slice_cpu_weight": int(slice_[
"cpu_weight"]),
413 "dev_slice_memory": f
"{slice_['memory_gb']}G",
414 "dev_slice_swap": f
"{slice_.get('swap_gb', 0)}G",
415 "dev_slice_max_jobs": int(slice_[
"max_jobs"]),
420 "dev_slice_ci_cpu_weight": SYSTEMD_DEFAULT_CPU_WEIGHT,
426def _capacity_vars(host: dict[str, Any]) -> dict[str, Any]:
427 """Ansible variables the ``fleet_capacity`` role needs to install a timer.
430 host: One host's declaration.
433 The role variables, including the quiet-hours window when one is
434 declared. ``fleet_capacity_enabled`` is false for a host with no
435 window, so the role removes a timer a previous declaration installed --
436 deleting a block must undo it, not orphan it.
438 cls = CLASSES[host[
"class"]]
439 if cls.capacity_kind ==
"none":
441 quiet = host.get(
"quiet_hours")
or {}
442 out: dict[str, Any] = {
443 "fleet_capacity_kind": cls.capacity_kind,
444 "fleet_capacity_full_instances": int(host[
"runners"][
"instances"]),
445 "fleet_capacity_enabled": bool(quiet),
447 if cls.capacity_kind ==
"docker":
448 out[
"fleet_capacity_docker"] = docker_command(host)
449 out[
"fleet_capacity_containers"] =
" ".join(container_names(host))
453 out[
"fleet_capacity_dev_slice"] = DEV_SLICE_UNIT
if host.get(
"dev_slice")
else ""
455 out[
"fleet_capacity_scale_set"] = host[
"runners"][
"labels"][0]
457 start, _, end = str(quiet[
"window"]).partition(
"-")
460 "fleet_capacity_quiet_instances": int(quiet[
"instances"]),
461 "fleet_capacity_quiet_start": start,
462 "fleet_capacity_quiet_end": end,
463 "fleet_capacity_quiet_days": str(quiet[
"days"]),
469def role_vars(data: dict[str, Any], name: str, host: dict[str, Any]) -> dict[str, Any]:
470 """Every Ansible variable derived from one host's declared block.
472 Extra-vars beat ``host_vars``; the declaration gate rejects a committed
473 duplicate. Roles retain policy defaults, while a fleet-owned identity may
474 default empty so standalone execution fails instead of drifting.
477 data: The complete fleet declaration, including the canonical image.
478 name: Fleet host name.
479 host: That host's declaration.
482 Variable name to value, ready to hand to ``ansible-playbook -e``.
485 **_runner_vars(name, host),
486 **fh.runner_vars(data, host),
487 **_runner_image_vars(data, host),
488 **_dev_slice_vars(host),
489 **_capacity_vars(host),
493def inventory_entry(data: dict[str, Any], name: str) -> str:
494 """One inventory line for a host.
497 data: The parsed declaration.
498 name: Fleet host name.
501 The ``<name> ansible_host=... ansible_user=...`` line, or a
502 ``connection=local`` line for a WSL host, whose play runs inside the
503 distro because WSL has no SSH daemon of its own.
505 host = data[
"hosts"][name]
506 if CLASSES[host[
"class"]].transport ==
"wsl":
507 return f
"{name} ansible_connection=local"
508 connect = host[
"connect"]
509 entry = f
"{name} ansible_host={connect['address']}"
510 if connect.get(
"user"):
511 entry += f
" ansible_user={connect['user']}"
512 hops = fr.jump_chain(data, name)
518 entry += f
" ansible_ssh_common_args='-o ProxyJump={','.join(hops)}'"
522def controller_inventory_entry() -> str:
523 """Return an explicit localhost entry only for the private service runtime."""
524 value = os.environ.get(
"ANSIBLE_LOCAL_TEMP")
528 safe =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-"
530 not path.is_absolute()
531 or str(path) != value
532 or ".." in path.parts
533 or any(character
not in safe
for character
in value)
535 message =
"ANSIBLE_LOCAL_TEMP cannot be represented safely in inventory"
536 raise ValueError(message)
537 return f
"localhost ansible_connection=local ansible_remote_tmp={value}"
540def render_inventory(data: dict[str, Any]) -> str:
541 """Generate the Ansible inventory from the declaration.
544 data: The parsed declaration.
547 An INI inventory body, one group per play group.
549 groups: dict[str, list[str]] = {}
550 for name, host
in data[
"hosts"].items():
551 entry = inventory_entry(data, name)
552 for play
in host[
"provisions"]:
553 group = groups.setdefault(PLAYS[play].group, [])
554 if entry
not in group:
557 "# GENERATED by scripts/dev/fleet.py from infra/fleet.yml -- do not edit.",
558 "# Add or retune a machine by editing that file; this is rewritten from",
559 "# it on every `just infra::*` run.",
562 controller = controller_inventory_entry()
564 lines.extend([
"[fleet_controller]", controller,
""])
565 for group_name
in sorted(groups):
566 lines.append(f
"[{group_name}]")
567 lines.extend(sorted(groups[group_name]))
569 return "\n".join(lines)
572def inventory_label() -> Path:
573 """Return a concise checkout-relative or exact runtime inventory path."""
575 return INVENTORY.relative_to(REPO_ROOT)
580def controller_inventory_selftest(data: dict[str, Any]) -> list[str]:
581 """Prove localhost uses the private service temp without inventory injection."""
582 failures: list[str] = []
583 previous = os.environ.get(
"ANSIBLE_LOCAL_TEMP")
585 with tempfile.TemporaryDirectory(prefix=
"ra8-controller-inventory-")
as raw:
586 local_temp = Path(raw) /
"ansible-local"
588 os.environ[
"ANSIBLE_LOCAL_TEMP"] = str(local_temp)
589 expected = f
"localhost ansible_connection=local ansible_remote_tmp={local_temp}"
590 if render_inventory(data).count(expected) != 1:
591 failures.append(
"private localhost remote temp was absent from inventory")
592 os.environ[
"ANSIBLE_LOCAL_TEMP"] = f
"{local_temp}\n[forged]"
594 render_inventory(data)
595 failures.append(
"unsafe localhost remote temp entered inventory")
600 os.environ.pop(
"ANSIBLE_LOCAL_TEMP",
None)
602 os.environ[
"ANSIBLE_LOCAL_TEMP"] = previous
606def _check_shape(name: str, host: dict[str, Any]) -> list[str]:
607 """Rule: a host names a real class, real plays, and a way to be reached.
610 name: Fleet host name.
611 host: That host's declaration.
614 One message per violation.
617 if host.get(
"class")
not in CLASSES:
618 return [f
"{name}: class '{host.get('class')}' is not one of {sorted(CLASSES)}"]
619 provisions = host.get(
"provisions")
or []
622 f
"{name}: provisions is empty, so `just infra::apply HOST={name}` would do nothing"
625 f
"{name}: provisions '{p}' is not a known play {sorted(PLAYS)}"
629 if CLASSES[host[
"class"]].transport ==
"wsl":
630 missing = [k
for k
in (
"distro",
"windows_user")
if not (host.get(
"connect")
or {}).get(k)]
631 bad += [f
"{name}: a docker_wsl host needs connect.{k}" for k
in missing]
635def _check_runner_block(name: str, host: dict[str, Any]) -> list[str]:
636 """Rule: runner classes declare capacity and a budget; others declare none.
639 name: Fleet host name.
640 host: That host's declaration.
643 One message per violation.
645 cls = CLASSES[host[
"class"]]
646 run, budget = host.get(
"runners"), host.get(
"budget")
647 if not cls.capacity_runner:
649 f
"{name}: class {host['class']} carries no runners, so '{key}:' is meaningless here"
650 for key
in (
"runners",
"budget",
"quiet_hours",
"dev_slice")
655 bad.append(f
"{name}: a runner host must declare runners.instances")
656 elif not run.get(
"labels"):
657 bad.append(f
"{name}: runners.labels is empty, so no `runs-on:` would ever reach it")
659 bad.append(f
"{name}: a runner host must declare a budget (threads, memory_gb)")
660 elif budget.get(
"mode") != cls.budget_mode:
662 f
"{name}: budget.mode is '{budget.get('mode')}' but class {host['class']} is "
663 f
"only honest as '{cls.budget_mode}' -- see the mode note in infra/fleet.yml"
665 if cls.transport ==
"wsl" and budget
and "swap_gb" not in budget:
666 bad.append(f
"{name}: a docker_wsl budget must set swap_gb (the VM's swap file)")
667 if run
and cls.budget_mode ==
"burst":
671 f
"{name}: a burst-mode host must declare runners.{key}"
672 for key
in (
"cpu_request",
"memory_request_gb")
678def _check_fit(name: str, host: dict[str, Any]) -> list[str]:
679 """Rule: what a host promises its runners must fit what CI may use.
681 ``reserved`` caps are kernel-enforced, so the caps themselves must fit;
682 ``burst`` caps are ceilings a scheduler may oversubscribe, so the requests
683 are what must fit. Applying the reserved arithmetic to a k8s scale set
684 would fail a shape that is correct, which is how a gate teaches people to
688 name: Fleet host name.
689 host: That host's declaration.
692 One message per violation.
694 run, budget = host[
"runners"], host[
"budget"]
695 count = int(run[
"instances"])
696 if budget[
"mode"] ==
"burst":
697 cpu, mem = int(run[
"cpu_request"]), int(run[
"memory_request_gb"])
700 cpu, mem = int(run[
"cpus"]), int(run[
"memory_gb"])
703 if count * cpu > int(budget[
"threads"]):
705 f
"{name}: {count} instances x {cpu} CPU {what} = {count * cpu} exceeds the "
706 f
"declared budget of {budget['threads']} threads"
708 if count * mem > int(budget[
"memory_gb"]):
710 f
"{name}: {count} instances x {mem} GB {what} = {count * mem} exceeds the "
711 f
"declared budget of {budget['memory_gb']} GB"
716def _sizing_deviations(host: dict[str, Any], sizing: dict[str, Any]) -> list[str]:
717 """Every way a host departs from what the sizing formula would give it.
720 host: One host's declaration.
721 sizing: The declaration's ``sizing:`` block.
724 One phrase per departure, empty when the host is sized by the formula.
726 run, budget = host[
"runners"], host[
"budget"]
728 int(sizing[
"build_parallelism"]),
729 int(sizing[
"memory_per_instance_gb"]),
732 if int(run[
"cpus"]) < par:
734 f
"{run['cpus']} CPUs per instance is under the pinned build parallelism "
735 f
"of {par}, so every job would be throttled below its own fan-out"
737 if int(run[
"memory_gb"]) < per_mem:
739 f
"{run['memory_gb']} GB per instance is under the {per_mem} GB clang-tidy "
740 "has been OOM-killed below, and an instance that OOMs mid-job presents as "
743 want = recommended_instances(sizing, budget)
744 if int(run[
"instances"]) != want:
746 f
"{run['instances']} instances, where min({budget['threads']}/{par}, "
747 f
"{budget['memory_gb']}/{per_mem}) gives {want}"
752def _check_sizing(name: str, host: dict[str, Any], sizing: dict[str, Any]) -> list[str]:
753 """Rule: a host is sized by the formula, or says in writing why it is not.
755 The formula is not a hard limit -- three hosts have real reasons to depart
756 from it, and pretending otherwise would either force wrong numbers or make
757 the rule something people learn to work around. What it does enforce is
758 that a departure is DELIBERATE and legible: no number in this fleet may be
759 one nobody can re-derive.
762 name: Fleet host name.
763 host: That host's declaration.
764 sizing: The declaration's ``sizing:`` block.
767 One message when the host departs from the formula with no written
768 reason, empty otherwise.
770 deviations = _sizing_deviations(host, sizing)
771 if not deviations
or str(host.get(
"sizing_note",
"")).strip():
773 joined =
"; ".join(deviations)
775 f
"{name}: departs from the sizing formula ({joined}) with no sizing_note. "
776 "Either use the formula's numbers or write down why not."
780def _check_dev_slice(name: str, host: dict[str, Any]) -> list[str]:
781 """Rule: a lent dev slice cannot take anything CI was promised.
783 The slice exists so an agent can verify on a runner host without CI
784 noticing, and the two properties that make that true are checked here
787 * **CPU is a weight below CI's.** The runner containers live in
788 ``system.slice`` at systemd's default weight, so a slice at or above that
789 would not yield to a job -- it would split the machine with one.
790 * **Memory is taken out of CI's reservation, not shared with it.** Memory
791 does not yield: a page a dev build holds is a page a job cannot have. So
792 the slice's cap plus every runner's cap must fit the budget, exactly as
793 the runners alone must.
796 name: Fleet host name.
797 host: That host's declaration.
800 One message per violation.
802 slice_ = host.get(
"dev_slice")
805 if CLASSES[host[
"class"]].capacity_kind !=
"docker":
807 f
"{name}: class {host['class']} runs no dev slice -- it is a cgroup on a "
808 "Docker host, and there is no role that would create one here"
811 f
"{name}: dev_slice.{key} is required (see the dev_slice note in infra/fleet.yml)"
812 for key
in (
"cpu_weight",
"memory_gb",
"max_jobs")
813 if not isinstance(slice_.get(key), int)
817 weight = int(slice_[
"cpu_weight"])
818 if not 1 <= weight <= CGROUP_MAX_CPU_WEIGHT:
819 bad.append(f
"{name}: dev_slice.cpu_weight must be 1..{CGROUP_MAX_CPU_WEIGHT}, got {weight}")
820 elif weight >= SYSTEMD_DEFAULT_CPU_WEIGHT:
822 f
"{name}: dev_slice.cpu_weight {weight} is not below the "
823 f
"{SYSTEMD_DEFAULT_CPU_WEIGHT} that system.slice -- where every runner "
824 "container lives -- carries, so dev work would compete with CI rather "
825 "than yield to it. That is the whole property the slice is for."
827 if int(slice_[
"max_jobs"]) < 1:
828 bad.append(f
"{name}: dev_slice.max_jobs must be at least 1")
829 budget, run = host[
"budget"], host[
"runners"]
830 reserved = int(run[
"instances"]) * int(run[
"memory_gb"])
831 lent = int(slice_[
"memory_gb"])
833 bad.append(f
"{name}: dev_slice.memory_gb must be at least 1")
834 elif reserved + lent > int(budget[
"memory_gb"]):
836 f
"{name}: {run['instances']} runner(s) x {run['memory_gb']} GB reserve "
837 f
"{reserved} GB and the dev slice caps at {lent} GB, which is "
838 f
"{reserved + lent} of a {budget['memory_gb']} GB budget. Memory does not "
839 "yield, so the slice must fit what the runners leave -- lower "
840 "dev_slice.memory_gb or raise budget.memory_gb."
842 swap = slice_.get(
"swap_gb", 0)
843 if not isinstance(swap, int)
or swap < 0:
844 bad.append(f
"{name}: dev_slice.swap_gb must be a non-negative integer, got {swap!r}")
845 elif swap > int(budget.get(
"swap_gb", 0)):
847 f
"{name}: dev_slice.swap_gb {swap} exceeds the {budget.get('swap_gb', 0)} GB "
848 "of swap this host's budget declares, so the cap could not be honoured"
853def _check_quiet_hours(name: str, host: dict[str, Any]) -> list[str]:
854 """Rule: a declared quiet-hours window is one a timer can actually be built from.
857 name: Fleet host name.
858 host: That host's declaration.
861 One message per violation.
863 quiet = host.get(
"quiet_hours")
867 window = str(quiet.get(
"window",
""))
868 start, sep, end = window.partition(
"-")
869 if not sep
or not all(_is_hhmm(part)
for part
in (start, end)):
870 bad.append(f
"{name}: quiet_hours.window '{window}' is not HH:MM-HH:MM")
871 days = [d.strip()
for d
in str(quiet.get(
"days",
"")).split(
",")
if d.strip()]
873 bad.append(f
"{name}: quiet_hours.days is empty; name the weekdays it applies to")
875 f
"{name}: quiet_hours.days '{d}' is not one of {list(WEEKDAYS)}"
879 declared = int(host[
"runners"][
"instances"])
880 target = quiet.get(
"instances")
881 if not isinstance(target, int)
or not 0 <= target < declared:
883 f
"{name}: quiet_hours.instances must be 0..{declared - 1} (it is a REDUCTION "
884 f
"from the declared {declared}); got {target!r}"
889def _is_hhmm(text: str) -> bool:
890 """Whether a string is a 24-hour ``HH:MM`` time.
896 True when systemd's ``OnCalendar`` would accept it as a time of day.
898 hours, _, minutes = text.strip().partition(
":")
899 if not (hours.isdigit()
and minutes.isdigit()):
901 return 0 <= int(hours) <= LAST_HOUR
and 0 <= int(minutes) <= LAST_MINUTE
904def _check_host_vars(data: dict[str, Any], host_vars_dir: Path) -> list[str]:
905 """Rule: no committed ``host_vars`` file re-declares a fleet-owned tunable.
907 Extra-vars beat ``host_vars``, so a duplicate would not change what runs --
908 it would do something worse: leave a number in the tree that looks
909 authoritative, that someone will edit, and that will have no effect. One
913 data: The parsed declaration.
914 host_vars_dir: Directory of committed per-host variable files.
917 One message per re-declared variable.
919 owned: set[str] = set()
920 for name, host
in data[
"hosts"].items():
921 owned |= set(role_vars(data, name, host))
923 for path
in sorted(host_vars_dir.glob(
"*.yml")):
924 loaded = yaml.safe_load(path.read_text(encoding=
"utf-8"))
or {}
926 f
"{path.name}: re-declares '{key}', which infra/fleet.yml owns. "
927 "Extra-vars beat host_vars, so this value would silently do nothing; "
928 "delete it and tune the host's fleet.yml block instead."
929 for key
in sorted(set(loaded) & owned)
934def validate(data: dict[str, Any], host_vars_dir: Path |
None =
None) -> list[str]:
935 """Every rule the declaration must satisfy, in one pass.
938 data: The parsed declaration.
939 host_vars_dir: Committed per-host variable files to cross-check against.
940 Defaults to the tree's; the selftest points it at a fixture.
943 One message per violation, empty when the fleet is well declared.
945 sizing = data[
"sizing"]
946 image = data[
"runner_image"]
948 f
"sizing.{key} must be a positive integer"
949 for key
in (
"build_parallelism",
"memory_per_instance_gb")
950 if not isinstance(sizing.get(key), int)
or sizing[key] <= 0
953 f
"runner_image.{key} must be a non-empty string"
954 for key
in (
"source_host",
"image",
"archive")
955 if not isinstance(image.get(key), str)
or not image[key].strip()
957 source = image.get(
"source_host")
958 if isinstance(source, str)
and source
and source
not in data[
"hosts"]:
959 problems.append(f
"runner_image.source_host '{source}' is not a declared host")
960 elif source
in data[
"hosts"]
and "ci-runner" not in data[
"hosts"][source].get(
"provisions", []):
962 f
"runner_image.source_host '{source}' does not provision ci-runner, "
963 "so no declared role produces its archive"
969 problems += fh.check_uniqueness(data[
"hosts"])
970 for name, host
in data[
"hosts"].items():
971 shape = _check_shape(name, host) + fr.check_connect(name, host, data[
"hosts"])
973 if shape
or host.get(
"class")
not in CLASSES:
975 problems += fh.check_runner(name, host, data[
"hosts"])
976 block = _check_runner_block(name, host)
981 if block
or not CLASSES[host[
"class"]].capacity_runner:
983 problems += _check_fit(name, host)
984 problems += _check_sizing(name, host, sizing)
985 problems += _check_quiet_hours(name, host)
986 problems += _check_dev_slice(name, host)
991 problems += _check_host_vars(data, host_vars_dir
or HOST_VARS_DIR)
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.