ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
fleet_model.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""The schema, arithmetic and derivations behind ``infra/fleet.yml``.
5
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`.
10"""
11
12from __future__ import annotations
13
14import os
15import sys
16import tempfile
17from dataclasses import dataclass
18from pathlib import Path
19from typing import Any
20
21import yaml
22
23sys.path.insert(0, str(Path(__file__).resolve().parent))
24
25import fleet_hil as fh
26import fleet_reach as fr
27import fleet_runner_model as frm
28
29CLASSES = frm.CLASSES
30
31REPO_ROOT = Path(__file__).resolve().parents[2]
32FLEET_FILE = REPO_ROOT / "infra" / "fleet.yml"
33ANSIBLE_DIR = REPO_ROOT / "infra" / "ansible"
34
35
36def _inventory_path() -> Path:
37 """Select the service's writable inventory without moving source authority."""
38 override = os.environ.get("RA8_FLEET_INVENTORY")
39 if override is None:
40 return ANSIBLE_DIR / "inventory" / "hosts.ini"
41 path = Path(override)
42 if not path.is_absolute():
43 message = "RA8_FLEET_INVENTORY must be an absolute path"
44 raise ValueError(message)
45 return path
46
47
48INVENTORY = _inventory_path()
49
50# Beside the inventory, not beside the playbooks. Ansible auto-loads host_vars
51# from the inventory SOURCE's directory; a host_vars tree anywhere else is
52# silently never read, which presents as a role running with its bare defaults
53# on a host that plainly declares otherwise.
54HOST_VARS_DIR = ANSIBLE_DIR / "inventory" / "host_vars"
55
56
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)
67
68
69# Days a quiet-hours window may name, in the spelling systemd's OnCalendar
70# accepts, so the declaration goes into a timer without translation.
71WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
72
73# Bounds of a 24-hour wall clock, for the quiet-hours window check.
74LAST_HOUR = 23
75LAST_MINUTE = 59
76
77
78@dataclass(frozen=True)
79class Play:
80 """One provisioning play: a playbook, the group it targets, and its roles.
81
82 Attributes:
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.
90 """
91
92 playbook: str
93 group: str
94 roles: tuple[str, ...]
95 removable: bool
96 summary: str
97
98
99PLAYS: dict[str, Play] = {
100 "dev-box": Play(
101 playbook="dev-box.yml",
102 group="dev_boxes",
103 roles=("dev_box",),
104 removable=False,
105 summary="the pinned host toolchain",
106 ),
107 "k3s-node": Play(
108 playbook="k3s-node.yml",
109 group="ci_runners",
110 roles=("k3s_node", "openbao"),
111 removable=False,
112 summary="k3s + helm + the vault",
113 ),
114 "ci-runner": Play(
115 playbook="ci-runner.yml",
116 group="ci_runners",
117 roles=("ci_runner",),
118 removable=False,
119 summary="the ARC autoscaling runner pool",
120 ),
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"),
125 removable=True,
126 summary="long-lived runner containers on a Docker host",
127 ),
128 "wsl-ci-host": Play(
129 playbook="wsl-ci-host.yml",
130 group="wsl_ci_hosts",
131 roles=("wsl_ci_host", "ci_runner_docker", "dev_slice", "fleet_capacity"),
132 removable=True,
133 summary="a Windows machine's WSL2 distro, then the runners into it",
134 ),
135 "hil-bench": Play(
136 playbook="hil-bench.yml",
137 group="hil_bench",
138 roles=("hil_bench", "c6_toolchain", "ad2_tools"),
139 removable=False,
140 summary="the HIL bench Pi, ESP32-C6 and AD2",
141 ),
142}
143
144
145# Ansible tags whose task set cannot stop, start or recreate a container, so a
146# converge limited to them needs no drain and costs the host no runner time.
147#
148# This is a whitelist rather than a judgement call at the call site: a tag
149# added here that DOES touch a container would silently make `fleet.py apply`
150# cancel jobs, which is the one failure the drain exists to prevent.
151NO_DRAIN_TAGS = frozenset({"capacity", "dev-slice"})
152
153# systemd's default CPUWeight, which is what `system.slice` carries -- and
154# every Docker container is a scope under `system.slice` unless it is given an
155# explicit `--cgroup-parent`. A dev slice is only a LOW-priority slice if its
156# weight is below this, so the number is named here and checked rather than
157# left as folklore in a role.
158SYSTEMD_DEFAULT_CPU_WEIGHT = 100
159
160# Upper bound cgroup v2 accepts for cpu.weight.
161CGROUP_MAX_CPU_WEIGHT = 10000
162
163# The systemd slice unit the dev_slice role installs.
164#
165# NO DASH IN THE NAME, and that is load-bearing rather than a style choice.
166# systemd reads `-` in a slice name as HIERARCHY: a unit called
167# `ra8-dev.slice` is created as a child of an auto-generated `ra8.slice`, which
168# systemd gives the DEFAULT weight. The dev slice's CPUWeight would then be
169# compared against its siblings inside `ra8.slice` -- of which there are none
170# -- while `ra8.slice` itself competed with `system.slice` at 100 against 100,
171# i.e. the runners and the dev work splitting the machine evenly. Verified on
172# the host: `systemd-run --slice=ra8-dev.slice` lands in
173# `/ra8.slice/ra8-dev.slice/...`. A single-token name is a root-level slice and
174# a direct sibling of `system.slice`, which is the comparison that matters.
175#
176# Named here because fleet.py has to pass it to the capacity script and the
177# role has to create it; the fleet-declaration gate asserts the role's default
178# agrees rather than trusting this copy.
179DEV_SLICE_UNIT = "ra8dev.slice"
180
181
182class FleetError(Exception):
183 """A fleet declaration could not be read or does not describe a real fleet."""
184
185
186def load(path: Path = FLEET_FILE) -> dict[str, Any]:
187 """Read and structurally check ``infra/fleet.yml``.
188
189 Args:
190 path: Declaration to read. Overridden only by the selftest.
191
192 Returns:
193 The parsed mapping, with ``sizing``, ``runner_image`` and ``hosts``
194 guaranteed present.
195
196 Raises:
197 FleetError: The file is missing, is not a mapping, or lacks either of
198 the two top-level keys everything else derives from.
199 """
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)
211 return data
212
213
214def recommended_instances(sizing: dict[str, Any], budget: dict[str, Any]) -> int:
215 """Instance count the sizing formula gives for a budget.
216
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
221 memory divisor.
222
223 Args:
224 sizing: The declaration's ``sizing:`` block.
225 budget: One host's ``budget:`` block.
226
227 Returns:
228 The recommended count, never below zero.
229 """
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))
233
234
235def instance_names(name: str, host: dict[str, Any]) -> list[str]:
236 """Runner registration names this host's instances will carry on GitHub.
237
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
242 registration.
243
244 Args:
245 name: Fleet host name, the default base.
246 host: That host's declaration.
247
248 Returns:
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.
252 """
253 return frm.instance_names(name, host)
254
255
256def container_names(host: dict[str, Any]) -> list[str]:
257 """Docker container names for this host's instances, in instance order.
258
259 Args:
260 host: One host's declaration.
261
262 Returns:
263 Container names the capacity script drains, empty for a non-container
264 class.
265 """
266 return frm.container_names(host)
267
268
269def remote_shell(host: dict[str, Any]) -> str:
270 """The remote command that reads a shell script on stdin and runs it.
271
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.
276
277 Args:
278 host: One host's declaration.
279
280 Returns:
281 A remote command string ending in ``bash -s``.
282 """
283 return frm.remote_shell(host)
284
285
286def docker_command(host: dict[str, Any]) -> str:
287 """How the capacity script must invoke Docker on this host.
288
289 Args:
290 host: One host's declaration.
291
292 Returns:
293 ``docker`` where the connecting user owns the socket, ``sudo docker``
294 otherwise.
295 """
296 return frm.docker_command(host)
297
298
299def _runner_vars(name: str, host: dict[str, Any]) -> dict[str, Any]:
300 """Ansible variables carrying this host's declared runner capacity.
301
302 Args:
303 name: Fleet host name.
304 host: That host's declaration.
305
306 Returns:
307 The role variables for the host's class, empty for a non-runner class.
308 """
309 if not CLASSES[host["class"]].capacity_runner:
310 return {}
311 run = host["runners"]
312 if host["class"] == "arc_k8s":
313 return {
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],
320 }
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,
327 # Equal to the memory cap, never larger: a swapping runner is one whose
328 # job has quietly become an order of magnitude slower, and that
329 # presents as a timeout rather than as the OOM it really is.
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"]),
333 }
334 if host["class"] == "docker_wsl":
335 out.update(_wsl_vars(host))
336 return out
337
338
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.
341
342 Args:
343 data: The complete fleet declaration.
344 host: One host's declaration.
345
346 Returns:
347 Image variables for the ARC producer or a Docker consumer, empty for a
348 machine that neither builds nor runs the shared image.
349 """
350 image = data["runner_image"]
351 if host["class"] == "arc_k8s":
352 return {
353 "ci_runner_image": image["image"],
354 "ci_runner_image_archive": image["archive"],
355 }
356 if CLASSES[host["class"]].capacity_kind == "docker":
357 return {
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"]),
362 }
363 return {}
364
365
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.
368
369 Args:
370 host: One ``docker_wsl`` host's declaration.
371
372 Returns:
373 The ``wsl_ci_host`` role variables written into ``.wslconfig``.
374 """
375 budget = host["budget"]
376 connect = host["connect"]
377 return {
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",
383 }
384
385
386def _dev_slice_vars(host: dict[str, Any]) -> dict[str, Any]:
387 """Ansible variables for the low-priority dev slice, if one is declared.
388
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).
393
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.
397
398 Args:
399 host: One host's declaration.
400
401 Returns:
402 The ``dev_slice`` role variables, empty for a class that carries none.
403 """
404 if CLASSES[host["class"]].capacity_kind != "docker":
405 return {}
406 slice_ = host.get("dev_slice") or {}
407 out: dict[str, Any] = {"dev_slice_enabled": bool(slice_)}
408 if not slice_:
409 return out
410 out.update(
411 {
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"]),
416 # The weight the slice must stay under to be a low-priority one.
417 # Passed rather than assumed by the role, so the role can read the
418 # host's REAL system.slice weight back and assert against the same
419 # number this validator used.
420 "dev_slice_ci_cpu_weight": SYSTEMD_DEFAULT_CPU_WEIGHT,
421 }
422 )
423 return out
424
425
426def _capacity_vars(host: dict[str, Any]) -> dict[str, Any]:
427 """Ansible variables the ``fleet_capacity`` role needs to install a timer.
428
429 Args:
430 host: One host's declaration.
431
432 Returns:
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.
437 """
438 cls = CLASSES[host["class"]]
439 if cls.capacity_kind == "none":
440 return {}
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),
446 }
447 if cls.capacity_kind == "docker":
448 out["fleet_capacity_docker"] = docker_command(host)
449 out["fleet_capacity_containers"] = " ".join(container_names(host))
450 # Quiet hours have to reach the dev slice too, or standing the runners
451 # down buys the owner nothing -- a gate suite in the slice would go on
452 # using the machine. Empty when the host lends no slice.
453 out["fleet_capacity_dev_slice"] = DEV_SLICE_UNIT if host.get("dev_slice") else ""
454 else:
455 out["fleet_capacity_scale_set"] = host["runners"]["labels"][0]
456 if quiet:
457 start, _, end = str(quiet["window"]).partition("-")
458 out.update(
459 {
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"]),
464 }
465 )
466 return out
467
468
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.
471
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.
475
476 Args:
477 data: The complete fleet declaration, including the canonical image.
478 name: Fleet host name.
479 host: That host's declaration.
480
481 Returns:
482 Variable name to value, ready to hand to ``ansible-playbook -e``.
483 """
484 return {
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),
490 }
491
492
493def inventory_entry(data: dict[str, Any], name: str) -> str:
494 """One inventory line for a host.
495
496 Args:
497 data: The parsed declaration.
498 name: Fleet host name.
499
500 Returns:
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.
504 """
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)
513 if hops:
514 # Ansible reaches a jumped host through its own ssh invocation, not
515 # through this module's, so the hops have to be handed to it too --
516 # otherwise a converge would be the one path that still needed an alias
517 # in somebody's ~/.ssh/config to work.
518 entry += f" ansible_ssh_common_args='-o ProxyJump={','.join(hops)}'"
519 return entry
520
521
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")
525 if value is None:
526 return ""
527 path = Path(value)
528 safe = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-"
529 if (
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)
534 ):
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}"
538
539
540def render_inventory(data: dict[str, Any]) -> str:
541 """Generate the Ansible inventory from the declaration.
542
543 Args:
544 data: The parsed declaration.
545
546 Returns:
547 An INI inventory body, one group per play group.
548 """
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:
555 group.append(entry)
556 lines = [
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.",
560 "",
561 ]
562 controller = controller_inventory_entry()
563 if controller:
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]))
568 lines.append("")
569 return "\n".join(lines)
570
571
572def inventory_label() -> Path:
573 """Return a concise checkout-relative or exact runtime inventory path."""
574 try:
575 return INVENTORY.relative_to(REPO_ROOT)
576 except ValueError:
577 return INVENTORY
578
579
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")
584 try:
585 with tempfile.TemporaryDirectory(prefix="ra8-controller-inventory-") as raw:
586 local_temp = Path(raw) / "ansible-local"
587 local_temp.mkdir()
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]"
593 try:
594 render_inventory(data)
595 failures.append("unsafe localhost remote temp entered inventory")
596 except ValueError:
597 pass
598 finally:
599 if previous is None:
600 os.environ.pop("ANSIBLE_LOCAL_TEMP", None)
601 else:
602 os.environ["ANSIBLE_LOCAL_TEMP"] = previous
603 return failures
604
605
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.
608
609 Args:
610 name: Fleet host name.
611 host: That host's declaration.
612
613 Returns:
614 One message per violation.
615 """
616 bad = []
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 []
620 if not provisions:
621 bad.append(
622 f"{name}: provisions is empty, so `just infra::apply HOST={name}` would do nothing"
623 )
624 bad += [
625 f"{name}: provisions '{p}' is not a known play {sorted(PLAYS)}"
626 for p in provisions
627 if p not in PLAYS
628 ]
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]
632 return bad
633
634
635def _check_runner_block(name: str, host: dict[str, Any]) -> list[str]:
636 """Rule: runner classes declare capacity and a budget; others declare none.
637
638 Args:
639 name: Fleet host name.
640 host: That host's declaration.
641
642 Returns:
643 One message per violation.
644 """
645 cls = CLASSES[host["class"]]
646 run, budget = host.get("runners"), host.get("budget")
647 if not cls.capacity_runner:
648 return [
649 f"{name}: class {host['class']} carries no runners, so '{key}:' is meaningless here"
650 for key in ("runners", "budget", "quiet_hours", "dev_slice")
651 if host.get(key)
652 ]
653 bad = []
654 if not run:
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")
658 if not budget:
659 bad.append(f"{name}: a runner host must declare a budget (threads, memory_gb)")
660 elif budget.get("mode") != cls.budget_mode:
661 bad.append(
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"
664 )
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":
668 # A burst host is packed by what it REQUESTS, so the requests are not
669 # optional extras -- without them there is no arithmetic to check.
670 bad += [
671 f"{name}: a burst-mode host must declare runners.{key}"
672 for key in ("cpu_request", "memory_request_gb")
673 if key not in run
674 ]
675 return bad
676
677
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.
680
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
685 ignore it.
686
687 Args:
688 name: Fleet host name.
689 host: That host's declaration.
690
691 Returns:
692 One message per violation.
693 """
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"])
698 what = "request"
699 else:
700 cpu, mem = int(run["cpus"]), int(run["memory_gb"])
701 what = "cap"
702 bad = []
703 if count * cpu > int(budget["threads"]):
704 bad.append(
705 f"{name}: {count} instances x {cpu} CPU {what} = {count * cpu} exceeds the "
706 f"declared budget of {budget['threads']} threads"
707 )
708 if count * mem > int(budget["memory_gb"]):
709 bad.append(
710 f"{name}: {count} instances x {mem} GB {what} = {count * mem} exceeds the "
711 f"declared budget of {budget['memory_gb']} GB"
712 )
713 return bad
714
715
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.
718
719 Args:
720 host: One host's declaration.
721 sizing: The declaration's ``sizing:`` block.
722
723 Returns:
724 One phrase per departure, empty when the host is sized by the formula.
725 """
726 run, budget = host["runners"], host["budget"]
727 par, per_mem = (
728 int(sizing["build_parallelism"]),
729 int(sizing["memory_per_instance_gb"]),
730 )
731 out = []
732 if int(run["cpus"]) < par:
733 out.append(
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"
736 )
737 if int(run["memory_gb"]) < per_mem:
738 out.append(
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 "
741 "a flaky gate"
742 )
743 want = recommended_instances(sizing, budget)
744 if int(run["instances"]) != want:
745 out.append(
746 f"{run['instances']} instances, where min({budget['threads']}/{par}, "
747 f"{budget['memory_gb']}/{per_mem}) gives {want}"
748 )
749 return out
750
751
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.
754
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.
760
761 Args:
762 name: Fleet host name.
763 host: That host's declaration.
764 sizing: The declaration's ``sizing:`` block.
765
766 Returns:
767 One message when the host departs from the formula with no written
768 reason, empty otherwise.
769 """
770 deviations = _sizing_deviations(host, sizing)
771 if not deviations or str(host.get("sizing_note", "")).strip():
772 return []
773 joined = "; ".join(deviations)
774 return [
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."
777 ]
778
779
780def _check_dev_slice(name: str, host: dict[str, Any]) -> list[str]:
781 """Rule: a lent dev slice cannot take anything CI was promised.
782
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
785 rather than trusted:
786
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.
794
795 Args:
796 name: Fleet host name.
797 host: That host's declaration.
798
799 Returns:
800 One message per violation.
801 """
802 slice_ = host.get("dev_slice")
803 if not slice_:
804 return []
805 if CLASSES[host["class"]].capacity_kind != "docker":
806 return [
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"
809 ]
810 bad = [
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)
814 ]
815 if bad:
816 return bad
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:
821 bad.append(
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."
826 )
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"])
832 if lent < 1:
833 bad.append(f"{name}: dev_slice.memory_gb must be at least 1")
834 elif reserved + lent > int(budget["memory_gb"]):
835 bad.append(
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."
841 )
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)):
846 bad.append(
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"
849 )
850 return bad
851
852
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.
855
856 Args:
857 name: Fleet host name.
858 host: That host's declaration.
859
860 Returns:
861 One message per violation.
862 """
863 quiet = host.get("quiet_hours")
864 if not quiet:
865 return []
866 bad = []
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()]
872 if not days:
873 bad.append(f"{name}: quiet_hours.days is empty; name the weekdays it applies to")
874 bad += [
875 f"{name}: quiet_hours.days '{d}' is not one of {list(WEEKDAYS)}"
876 for d in days
877 if d not in WEEKDAYS
878 ]
879 declared = int(host["runners"]["instances"])
880 target = quiet.get("instances")
881 if not isinstance(target, int) or not 0 <= target < declared:
882 bad.append(
883 f"{name}: quiet_hours.instances must be 0..{declared - 1} (it is a REDUCTION "
884 f"from the declared {declared}); got {target!r}"
885 )
886 return bad
887
888
889def _is_hhmm(text: str) -> bool:
890 """Whether a string is a 24-hour ``HH:MM`` time.
891
892 Args:
893 text: Candidate.
894
895 Returns:
896 True when systemd's ``OnCalendar`` would accept it as a time of day.
897 """
898 hours, _, minutes = text.strip().partition(":")
899 if not (hours.isdigit() and minutes.isdigit()):
900 return False
901 return 0 <= int(hours) <= LAST_HOUR and 0 <= int(minutes) <= LAST_MINUTE
902
903
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.
906
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
910 knob, one home.
911
912 Args:
913 data: The parsed declaration.
914 host_vars_dir: Directory of committed per-host variable files.
915
916 Returns:
917 One message per re-declared variable.
918 """
919 owned: set[str] = set()
920 for name, host in data["hosts"].items():
921 owned |= set(role_vars(data, name, host))
922 bad = []
923 for path in sorted(host_vars_dir.glob("*.yml")):
924 loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
925 bad.extend(
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)
930 )
931 return bad
932
933
934def validate(data: dict[str, Any], host_vars_dir: Path | None = None) -> list[str]:
935 """Every rule the declaration must satisfy, in one pass.
936
937 Args:
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.
941
942 Returns:
943 One message per violation, empty when the fleet is well declared.
944 """
945 sizing = data["sizing"]
946 image = data["runner_image"]
947 problems = [
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
951 ]
952 problems += [
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()
956 ]
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", []):
961 problems.append(
962 f"runner_image.source_host '{source}' does not provision ci-runner, "
963 "so no declared role produces its archive"
964 )
965 # Every per-host rule below divides by these, so there is nothing further
966 # to say about a fleet whose formula constants do not exist.
967 if problems:
968 return problems
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"])
972 problems += shape
973 if shape or host.get("class") not in CLASSES:
974 continue
975 problems += fh.check_runner(name, host, data["hosts"])
976 block = _check_runner_block(name, host)
977 problems += block
978 # The arithmetic below reads keys the block check has just proved
979 # present; running it over an incomplete host would raise rather than
980 # report, and a checker that crashes teaches nothing.
981 if block or not CLASSES[host["class"]].capacity_runner:
982 continue
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)
987 if not problems:
988 # Only once the declaration itself is sound: role_vars() derives the
989 # owned-name set from it, so running this over a broken declaration
990 # would report a fabricated overlap.
991 problems += _check_host_vars(data, host_vars_dir or HOST_VARS_DIR)
992 return problems
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157