ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
fleet.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"""Drive the CI fleet from ``infra/fleet.yml``.
5
6Commands name a declared host, derive its reachability, and expose read-only
7inspection, inventory generation, guarded convergence, registration, removal,
8and capacity control. Mutating convergence drains runner capacity and binds
9bench-affecting work to the repository's authenticated whole-bench hold.
10"""
11
12from __future__ import annotations
13
14import argparse
15import os
16import subprocess
17import sys
18import tempfile
19from collections.abc import Callable, Mapping
20from dataclasses import dataclass
21from pathlib import Path
22from threading import Event, Thread
23from typing import Any, Protocol
24
25sys.path.insert(0, str(Path(__file__).resolve().parent))
26
27import fleet_bench as fb
28import fleet_capacity_client as fcc
29import fleet_model as fm
30import fleet_mutation_lock as fml
31import fleet_reach as fr
32import fleet_runner_maintenance as frm
33import fleet_ssh_config as fsc
34import fleet_typed_vars as ftv
35import fleet_wsl as fw
36
37IDLE_STOP_HELPER = fm.REPO_ROOT / "infra/ansible/roles/dev_box/files/ra8-hil-runner-idle-stop.py"
38MUTATING_COMMANDS = frozenset(
39 {
40 "register-runner",
41 "register-hil",
42 "apply",
43 "reconcile-parked-apply",
44 "reconcile-parked-check",
45 "reconcile-activate",
46 "remove",
47 "capacity-quarantine",
48 "capacity-restore",
49 "scale",
50 }
51)
52
53
54class _SubparserGroup(Protocol):
55 """Expose the parser-factory operation used to build fleet subcommands."""
56
57 def add_parser(self, name: str, **kwargs: object) -> argparse.ArgumentParser:
58 """Create and return one named subparser."""
59 ...
60
61
62def _fail(message: str) -> int:
63 """Print an error to stderr and give the caller a shell exit status.
64
65 Args:
66 message: What went wrong, in one line.
67
68 Returns:
69 Always 2, the usage/precondition status this tool exits with.
70 """
71 print(f"fleet: error: {message}", file=sys.stderr)
72 return 2
73
74
75def _host(data: dict[str, Any], name: str) -> dict[str, Any]:
76 """Look one host up in the declaration.
77
78 Args:
79 data: The parsed declaration.
80 name: Fleet host name.
81
82 Returns:
83 That host's block.
84
85 Raises:
86 FleetError: No host of that name is declared.
87 """
88 if name not in data["hosts"]:
89 msg = f"no host '{name}' in infra/fleet.yml. Declared: {', '.join(data['hosts'])}"
90 raise fm.FleetError(msg)
91 return data["hosts"][name]
92
93
94def _run(
95 argv: list[str],
96 stdin: str | bytes | None = None,
97 cwd: Path | None = None,
98 env: Mapping[str, str] | None = None,
99 subprocess_kwargs: Mapping[str, object] | None = None,
100) -> int:
101 """Run a command, streaming its output, and return its status.
102
103 Args:
104 argv: Fully resolved argument vector.
105 stdin: Text (or bytes, for a tar stream) to feed the command. The
106 ``bash -s`` transport carries whole scripts this way, which keeps
107 them clear of both the Windows shell's quoting and the remote
108 shell's.
109 cwd: Directory to run in. Ansible needs ``infra/ansible``: both the
110 playbook paths and ``ansible.cfg`` are resolved relative to it.
111 env: Exact child environment, or the caller's environment when absent.
112 subprocess_kwargs: Narrow trusted FD inheritance for bench re-entry only.
113
114 Returns:
115 The command's exit status.
116 """
117 options: dict[str, object] = {
118 "input": stdin,
119 "text": not isinstance(stdin, bytes),
120 "cwd": cwd,
121 "env": env,
122 }
123 if subprocess_kwargs is not None:
124 options.update(subprocess_kwargs)
125 proc = subprocess.run( # noqa: S603 -- argv is built from the declaration, never a shell string
126 argv, check=False, **options
127 )
128 return proc.returncode
129
130
131def cmd_list(data: dict[str, Any], _args: argparse.Namespace) -> int:
132 """Print every declared host with its class, capacity and schedule.
133
134 Args:
135 data: The parsed declaration.
136 _args: Unused; the command takes no arguments.
137
138 Returns:
139 0.
140 """
141 print(
142 f"{'HOST':<10} {'CLASS':<14} {'INSTANCES':<10} "
143 f"{'PER INSTANCE':<18} {'QUIET HOURS':<32} PLAYS"
144 )
145 for name, host in data["hosts"].items():
146 run = host.get("runners") or {}
147 quiet = host.get("quiet_hours") or {}
148 count = str(run.get("instances", "-"))
149 per = f"{run['cpus']} cpu / {run['memory_gb']} GB" if run else "-"
150 window = f"{quiet['window']} {quiet['days']} -> {quiet['instances']}" if quiet else "-"
151 print(
152 f"{name:<10} {host['class']:<14} {count:<10} {per:<18} {window:<32} "
153 f"{','.join(host['provisions'])}"
154 )
155 print()
156 print("just infra::check <host> dry run (changes nothing)")
157 print("just infra::apply <host> converge to the declaration")
158 print("just infra::scale <host> <count> live capacity change; shrinking DRAINS")
159 print("just infra::ssh_config name these machines in your ~/.ssh/config")
160 print("docs/CI_FLEET.md add a host, retune one, quiet hours")
161 return 0
162
163
164def cmd_show(data: dict[str, Any], args: argparse.Namespace) -> int:
165 """Print one host's declaration and everything derived from it.
166
167 Args:
168 data: The parsed declaration.
169 args: Parsed command line; uses ``args.host``.
170
171 Returns:
172 0.
173 """
174 host = _host(data, args.host)
175 cls = fm.CLASSES[host["class"]]
176 print(f"{args.host}: {host.get('summary', '')}")
177 print(f" class {host['class']} ({cls.summary})")
178 print(f" transport {cls.transport}")
179 print(f" reachable as {fr.ssh_destination(host)}")
180 hops = fr.jump_chain(data, args.host)
181 if hops:
182 print(f" via {' -> '.join(hops)}")
183 print(f" provisions {', '.join(host['provisions'])}")
184 if cls.capacity_runner:
185 want = fm.recommended_instances(data["sizing"], host["budget"])
186 print(f" instances {host['runners']['instances']} (formula gives {want})")
187 if fm.container_names(host):
188 print(f" registrations {', '.join(fm.instance_names(args.host, host))}")
189 print(f" containers {', '.join(fm.container_names(host))}")
190 hil = host.get("hil_runner")
191 if hil:
192 print(f" HIL listener {hil['name']} ({','.join(hil['labels'])})")
193 print(f" HIL workflow {hil['workflow']}")
194 print(f" HIL bench {hil['bench']['host']}")
195 lent = host.get("dev_slice")
196 if lent:
197 print(
198 f" dev slice {fm.DEV_SLICE_UNIT}: CPUWeight {lent['cpu_weight']} "
199 f"(vs {fm.SYSTEMD_DEFAULT_CPU_WEIGHT} for CI), MemoryMax "
200 f"{lent['memory_gb']}G, swap {lent.get('swap_gb', 0)}G, "
201 f"-j{lent['max_jobs']}"
202 )
203 print(" derived ansible variables:")
204 for key, value in sorted(fm.role_vars(data, args.host, host).items()):
205 print(f" {key}: {value}")
206 return 0
207
208
209def cmd_validate(data: dict[str, Any], _args: argparse.Namespace) -> int:
210 """Report every rule the declaration breaks.
211
212 Args:
213 data: The parsed declaration.
214 _args: Unused; the command takes no arguments.
215
216 Returns:
217 0 when the fleet is well declared, 1 otherwise.
218 """
219 problems = fm.validate(data)
220 if problems:
221 print(f"infra/fleet.yml: {len(problems)} problem(s):", file=sys.stderr)
222 for problem in problems:
223 print(f" {problem}", file=sys.stderr)
224 return 1
225 hosts = data["hosts"]
226 runners = sum(int((h.get("runners") or {}).get("instances", 0)) for h in hosts.values())
227 native_hil = sum(1 for host in hosts.values() if host.get("hil_runner"))
228 print(
229 f"infra/fleet.yml OK: {len(hosts)} host(s), {runners} capacity-managed "
230 f"runner instance(s), {native_hil} native HIL listener(s)"
231 )
232 return 0
233
234
235def _publish_inventory(body: str) -> None:
236 """Atomically publish one complete shared Ansible inventory generation."""
237 fm.INVENTORY.parent.mkdir(parents=True, exist_ok=True)
238 fd, raw = tempfile.mkstemp(prefix=f".{fm.INVENTORY.name}.", dir=fm.INVENTORY.parent)
239 temporary = Path(raw)
240 try:
241 os.fchmod(fd, 0o644)
242 with os.fdopen(fd, "w", encoding="utf-8") as stream:
243 stream.write(body)
244 stream.flush()
245 os.fsync(stream.fileno())
246 temporary.replace(fm.INVENTORY)
247 directory = os.open(fm.INVENTORY.parent, os.O_RDONLY | os.O_DIRECTORY)
248 try:
249 os.fsync(directory)
250 finally:
251 os.close(directory)
252 finally:
253 temporary.unlink(missing_ok=True)
254
255
256def _inventory_publication_selftest() -> list[str]:
257 """Prove concurrent readers observe only complete inventory generations."""
258 failures: list[str] = []
259 original = fm.INVENTORY
260 with tempfile.TemporaryDirectory(prefix="ra8-fleet-inventory-") as raw:
261 fm.INVENTORY = Path(raw) / "hosts.ini"
262 first = "[fleet]\n" + "a=1\n" * 8192
263 second = "[fleet]\n" + "b=2\n" * 8192
264 fm.INVENTORY.write_text(first, encoding="utf-8")
265 stopped = Event()
266 partial: list[str] = []
267
268 def read_generations() -> None:
269 while not stopped.is_set():
270 observed = fm.INVENTORY.read_text(encoding="utf-8")
271 if observed not in {first, second}:
272 partial.append(observed)
273 stopped.set()
274
275 reader = Thread(target=read_generations)
276 reader.start()
277 try:
278 for index in range(32):
279 _publish_inventory(first if index % 2 else second)
280 finally:
281 stopped.set()
282 reader.join()
283 fm.INVENTORY = original
284 if partial:
285 failures.append("concurrent inventory reader observed a partial generation")
286 return failures
287
288
289def cmd_inventory(data: dict[str, Any], args: argparse.Namespace) -> int:
290 """Generate the Ansible inventory from the declaration.
291
292 Args:
293 data: The parsed declaration.
294 args: Parsed command line; uses ``args.stdout``.
295
296 Returns:
297 0.
298 """
299 body = fm.render_inventory(data)
300 if args.stdout:
301 print(body, end="")
302 return 0
303 _publish_inventory(body)
304 print(f"wrote {fm.inventory_label()} ({len(data['hosts'])} host(s))")
305 return 0
306
307
308def cmd_ssh_config(data: dict[str, Any], args: argparse.Namespace) -> int:
309 """Print or install the declaration-derived SSH config fragment."""
310 return fsc.command(data, install=args.install)
311
312
313def cmd_ssh_target(data: dict[str, Any], args: argparse.Namespace) -> int:
314 """Print the ssh command that reaches one host from this machine.
315
316 The declaration is the only place that knows a host is behind a jump, or
317 which account it is entered as, so the scripts that probe a machine ask
318 here rather than spelling an alias of their own. Every token is
319 whitespace-free by construction -- the fleet-declaration gate rejects an
320 address, user or jump containing any -- so a caller may split the line on
321 spaces and use it as an argv.
322
323 Args:
324 data: The parsed declaration.
325 args: Parsed command line; uses ``args.host``.
326
327 Returns:
328 0.
329 """
330 _host(data, args.host)
331 print(" ".join(fr.ssh_target(data, args.host)))
332 return 0
333
334
335def _plays_for(host: dict[str, Any], only: str | None) -> list[str]:
336 """Which plays an apply or check should run.
337
338 Args:
339 host: One host's declaration.
340 only: A single play the caller asked for, or None for all of them.
341
342 Returns:
343 The plays in declared order, or empty when ``only`` is not one of them.
344 """
345 if only is None:
346 return list(host["provisions"])
347 return [only] if only in host["provisions"] else []
348
349
350def _converge_refusal(args: argparse.Namespace, host: dict[str, Any], plays: list[str]) -> str:
351 """Why this converge must not run, if it must not.
352
353 Args:
354 args: Parsed command line.
355 host: The target host's declaration.
356 plays: Plays the caller selected.
357
358 Returns:
359 The refusal, or an empty string when the converge may proceed.
360 """
361 if not plays:
362 return f"{args.host} does not provision '{args.play}' ({', '.join(host['provisions'])})"
363 if args.mode == "remove" and not all(fm.PLAYS[p].removable for p in plays):
364 return (
365 f"{args.host} runs {', '.join(plays)}, and not every one of those roles owns "
366 "both halves of its lifecycle. Removing it would mean undoing the rest by "
367 "hand, which is the drift the roles exist to prevent -- add a teardown path "
368 "to the role instead of tearing it down manually."
369 )
370 boundary = fb.control_flow_refusal(
371 fb.FlowRequest(
372 str(host["class"]),
373 plays,
374 args.mode,
375 args.tags,
376 args.extra_var,
377 bool(getattr(args, "trusted_tags", False)),
378 )
379 )
380 if boundary:
381 return boundary
382 return ""
383
384
385def _restore_after_converge(data: dict[str, Any], args: argparse.Namespace, rc: int) -> int:
386 """Restore declared capacity after a drained converge, preserving failure."""
387 if rc:
388 quarantine = fcc.run(data, args.host, ["quarantine"], _run)
389 return rc or quarantine
390 return fcc.run(data, args.host, ["restore"], _run)
391
392
393def _is_parked_command(command: str) -> bool:
394 """Return whether reconciliation requires zero admission through postcheck."""
395 return command in {"reconcile-parked-apply", "reconcile-parked-check"}
396
397
398def _converge_extra(host: dict[str, Any], args: argparse.Namespace) -> list[str]:
399 """Build Ansible flags without weakening credential handling."""
400 extra = (["--check", "--diff"] if args.mode == "check" else []) + _remove_flags(host, args)
401 if _is_parked_command(args.command):
402 extra += ["-e", "fleet_reconcile_parked=true"]
403 if args.command in {"reconcile-activate", "reconcile-activation-check"}:
404 extra += ["-e", "fleet_reconcile_activation_hold=true"]
405 # SHORT-LIVED credentials only, and preferably by file reference.
406 #
407 # Anything given as KEY=VALUE lands in this process's argv and in
408 # ansible-playbook's, where `ps` on the control node can read it. That is
409 # tolerable only for non-secret compatibility variables. Credentials use
410 # the typed commands below, which validate and snapshot mode-0600 files
411 # before any inventory write, drain, staging, or remote command.
412 for pair in args.extra_var:
413 extra += ["-e", pair]
414 if args.tags:
415 extra += ["--tags", args.tags]
416 return extra
417
418
419def _registration_args(
420 host: str,
421 play: str | None,
422 tags: str,
423 typed_vars: ftv.TypedVars,
424 original_argv: list[str],
425) -> argparse.Namespace:
426 """Build the internal apply request shared by typed registration commands."""
427 return argparse.Namespace(
428 command="apply",
429 mode="apply",
430 host=host,
431 play=play,
432 no_drain=False,
433 extra_var=[],
434 tags=tags,
435 vars_file="",
436 typed_vars=typed_vars,
437 trusted_tags=True,
438 original_argv=original_argv,
439 )
440
441
442def cmd_register_runner(data: dict[str, Any], args: argparse.Namespace) -> int:
443 """Register a declared Docker runner host from one schema-limited vars file."""
444 host = _host(data, args.host)
445 if host["class"] not in ftv.CONTAINER_RUNNER_CLASSES:
446 return _fail(
447 f"{args.host} is class {host['class']}; runner registration is limited to "
448 f"{', '.join(sorted(ftv.CONTAINER_RUNNER_CLASSES))}"
449 )
450 provisions = list(host["provisions"])
451 if len(provisions) != 1 or provisions[0] not in ftv.CONTAINER_RUNNER_PLAYS:
452 return _fail(
453 f"{args.host} does not have one typed container-runner play: {', '.join(provisions)}"
454 )
455 typed_vars = ftv.read_typed_vars_file(args.vars_file, ftv.RUNNER_REGISTRATION)
456 request = _registration_args(args.host, provisions[0], "", typed_vars, args.original_argv)
457 return cmd_converge(data, request)
458
459
460def cmd_register_hil(data: dict[str, Any], args: argparse.Namespace) -> int:
461 """Register the one declared native HIL listener from a typed vars file."""
462 candidates = [name for name, host in data["hosts"].items() if "hil_runner" in host]
463 if len(candidates) != 1:
464 return _fail(f"expected exactly one declared HIL listener, found {len(candidates)}")
465 name = candidates[0]
466 host = _host(data, name)
467 if host["class"] != "dev_box" or "dev-box" not in host["provisions"]:
468 return _fail(f"declared HIL listener {name} is not provisioned by the dev-box role")
469 typed_vars = ftv.read_typed_vars_file(args.vars_file, ftv.HIL_REGISTRATION)
470 request = _registration_args(name, "dev-box", "hil-runner", typed_vars, args.original_argv)
471 return cmd_converge(data, request)
472
473
474def _typed_vars_for_converge(
475 args: argparse.Namespace, host: dict[str, Any]
476) -> ftv.TypedVars | None:
477 """Validate typed vars before inventory writes, draining, or remote work."""
478 typed_vars = getattr(args, "typed_vars", None)
479 vars_file = getattr(args, "vars_file", "")
480 if vars_file:
481 if args.mode != "remove":
482 message = "--vars-file is accepted only by the typed remove operation"
483 raise fm.FleetError(message)
484 if host["class"] not in ftv.CONTAINER_RUNNER_CLASSES:
485 message = "runner removal vars are accepted only for container-runner hosts"
486 raise fm.FleetError(message)
487 typed_vars = ftv.read_typed_vars_file(vars_file, ftv.RUNNER_REMOVAL)
488 if any(value.startswith("@") for value in args.extra_var):
489 message = (
490 "raw -e @file is not accepted; use register-runner, register-hil, or "
491 "remove --vars-file so the file is validated before side effects"
492 )
493 raise fm.FleetError(message)
494 return typed_vars
495
496
497@dataclass(frozen=True)
498class _ConvergeTransport:
499 """Carry one validated converge transaction across its transport boundary."""
500
501 data: dict[str, Any]
502 args: argparse.Namespace
503 host: dict[str, Any]
504 plays: list[str]
505 extra: list[str]
506 typed_vars: ftv.TypedVars | None
507 no_drain_tags: bool
508
509
510def _bench_guard_argv(
511 host: dict[str, Any], plays: list[str], args: argparse.Namespace
512) -> list[str]:
513 """Build the outer whole-bench transaction before any side effect."""
514 try:
515 return fb.guarded_argv(
516 fb.GuardRequest(
517 fm.REPO_ROOT,
518 Path(__file__).resolve(),
519 args.original_argv,
520 host["class"],
521 plays,
522 args.mode,
523 os.environ,
524 )
525 )
526 except ValueError as exc:
527 raise fm.FleetError(str(exc)) from exc
528
529
530def _bench_ansible_extra(
531 host: dict[str, Any], plays: list[str], args: argparse.Namespace
532) -> list[str]:
533 """Carry the authenticated outer hold into the remote role."""
534 try:
535 return fb.ansible_extra(host["class"], plays, args.mode, os.environ)
536 except ValueError as exc:
537 raise fm.FleetError(str(exc)) from exc
538
539
540def _runner_maintenance_request(
541 data: dict[str, Any],
542 host: dict[str, Any],
543 args: argparse.Namespace,
544 extra: list[str],
545) -> frm.MaintenanceRequest:
546 """Build the read-only preview and declared idle-stop transport."""
547 preview = frm.playbook_argv(
548 data,
549 args.host,
550 host,
551 "dev-box",
552 ["--check", "--diff", *extra],
553 )
554 remote = "/usr/bin/sudo -n /usr/bin/python3 - ra8-hil-runner.service"
555 return frm.MaintenanceRequest(
556 preview,
557 fm.ANSIBLE_DIR,
558 os.environ,
559 args.host,
560 [*fr.ssh_target(data, args.host), remote],
561 IDLE_STOP_HELPER.read_text(encoding="utf-8"),
562 )
563
564
565def _prepare_native_runner(request: _ConvergeTransport) -> frm.MaintenanceDecision:
566 """Preview exact drift and stop only an idle listener when needed."""
567 if not frm.applies(request.host["class"], request.plays, request.args.mode):
568 return frm.MaintenanceDecision(proceed=True, status=0)
569 if request.typed_vars is None:
570 maintenance = _runner_maintenance_request(
571 request.data, request.host, request.args, request.extra
572 )
573 return frm.prepare(maintenance)
574 with ftv.local_vars_snapshot(request.typed_vars) as snapshot:
575 guarded_extra = [*request.extra, "-e", f"@{snapshot}"]
576 maintenance = _runner_maintenance_request(
577 request.data, request.host, request.args, guarded_extra
578 )
579 return frm.prepare(maintenance)
580
581
582def _run_converge_transport(request: _ConvergeTransport) -> int:
583 """Run one already-validated converge over its declared transport."""
584 if fm.CLASSES[request.host["class"]].transport == "wsl":
585 spec = fw.ConvergeSpec(
586 request.data,
587 request.args.host,
588 request.plays,
589 request.extra,
590 request.typed_vars,
591 request.args.mode,
592 )
593 return fw.converge(
594 spec,
595 sync_image=request.args.mode == "apply" and not request.no_drain_tags,
596 run=_run,
597 )
598 if request.typed_vars is None:
599 return _converge_ssh(request.data, request.args.host, request.plays, request.extra)
600 with ftv.local_vars_snapshot(request.typed_vars) as snapshot:
601 extra = [*request.extra, "-e", f"@{snapshot}"]
602 return _converge_ssh(request.data, request.args.host, request.plays, extra)
603
604
605def _bench_guard_subprocess_kwargs(
606 command: str, capability: Callable[[], dict[str, object]]
607) -> dict[str, object] | None:
608 """Carry the live guardian only through a mutating bench re-entry."""
609 return capability() if command in MUTATING_COMMANDS else None
610
611
612def _bench_guard_inheritance_selftest() -> list[str]:
613 """Prove only mutating bench re-entry inherits the live guardian FD."""
614 failures: list[str] = []
615 calls = 0
616 expected = {"env": {"RA8_FLEET_MUTATION_GUARDIAN_FD": "9"}, "pass_fds": (9,)}
617
618 def capability() -> dict[str, object]:
619 nonlocal calls
620 calls += 1
621 return expected
622
623 if _bench_guard_subprocess_kwargs("apply", capability) != expected or calls != 1:
624 failures.append("mutating bench re-entry dropped its guardian capability")
625 if _bench_guard_subprocess_kwargs("check", capability) is not None or calls != 1:
626 failures.append("read-only bench re-entry inherited a mutation capability")
627 return failures
628
629
630def cmd_converge(data: dict[str, Any], args: argparse.Namespace) -> int:
631 """Run a dry check or a guarded real converge of one host's plays.
632
633 Container-host applies drain first. A reconciler-only parked apply leaves
634 capacity at zero for its caller's postcheck. Bench-host applies re-enter
635 under the physical bench lock before inventory generation or remote work.
636 """
637 host = _host(data, args.host)
638 parked = _is_parked_command(args.command)
639 plays = _plays_for(host, args.play)
640 refusal = _converge_refusal(args, host, plays)
641 if parked and not host.get("runners"):
642 refusal = "parked reconciliation is limited to capacity-managed runner hosts"
643 if refusal:
644 return _fail(refusal)
645 guard = _bench_guard_argv(host, plays, args)
646 if guard:
647 guardian = _bench_guard_subprocess_kwargs(args.command, fml.guardian_subprocess_kwargs)
648 return _run(guard, cwd=fm.REPO_ROOT, subprocess_kwargs=guardian)
649 typed_vars = _typed_vars_for_converge(args, host)
650 rc = cmd_inventory(data, argparse.Namespace(stdout=False))
651 if rc:
652 return rc
653 # Some tag sets cannot stop, start or recreate a container -- `capacity`
654 # refreshes the drain script and the quiet-hours timer, `dev-slice` tunes a
655 # cgroup beside them. Draining the host for either would cost it every
656 # running job's worth of runner time to protect against a change that
657 # cannot touch them. The whitelist lives in fleet_model.NO_DRAIN_TAGS so
658 # adding a tag is a deliberate act with the rule in front of you.
659 no_drain_tags = args.tags in fm.NO_DRAIN_TAGS
660 extra = _converge_extra(host, args)
661 extra += _bench_ansible_extra(host, plays, args)
662 request = _ConvergeTransport(data, args, host, plays, extra, typed_vars, no_drain_tags)
663 maintenance = _prepare_native_runner(request)
664 if not maintenance.proceed:
665 return maintenance.status
666 drain = (
667 args.mode == "apply"
668 and not args.no_drain
669 and not no_drain_tags
670 and (parked or fm.container_names(host))
671 )
672 if drain:
673 print(f"==> parking {args.host} before converging (a converge changes admission)")
674 # Persistent containers use drain-all because a 1 <-> N declaration
675 # change renames them. ARC admission is a direct zero scale; its
676 # ephemeral controller deletes only runners that hold no job.
677 rc = fcc.run(data, args.host, ["maintenance-enter"], _run)
678 if rc:
679 return _fail("could not drain the host; refusing to converge over running jobs")
680 rc = _run_converge_transport(request)
681 if drain and not parked:
682 # Interactive convergence restores the declared service after either
683 # outcome. The reconciler uses the parked command instead and owns
684 # postcheck, receipt publication, and the eventual capacity restore.
685 rc = _restore_after_converge(data, args, rc)
686 return rc
687
688
689def _converge_ssh(data: dict[str, Any], name: str, plays: list[str], extra: list[str]) -> int:
690 """Run a host's plays with Ansible over ssh, from the control node.
691
692 Args:
693 data: The parsed declaration.
694 name: Fleet host name.
695 plays: Plays to run, in order.
696 extra: Extra ansible flags (dry-run, teardown state).
697
698 Returns:
699 0 on success, the first failing play's status otherwise.
700 """
701 host = data["hosts"][name]
702 for play in plays:
703 argv = frm.playbook_argv(data, name, host, play, extra)
704 print(f"==> ansible-playbook {fm.PLAYS[play].playbook} --limit {name}")
705 rc = _run(
706 argv,
707 cwd=fm.ANSIBLE_DIR,
708 env=frm.ansible_environment(os.environ, fm.ANSIBLE_DIR),
709 )
710 if rc:
711 return rc
712 return 0
713
714
715def _remove_flags(host: dict[str, Any], args: argparse.Namespace) -> list[str]:
716 """The extra-vars that turn a converge into a teardown.
717
718 Args:
719 host: One host's declaration.
720 args: Parsed command line.
721
722 Returns:
723 The ``state=absent`` flags for a removal, empty otherwise.
724 """
725 if args.mode != "remove":
726 return []
727 flags = [
728 "-e",
729 "ci_runner_docker_state=absent",
730 "-e",
731 "fleet_capacity_enabled=false",
732 ]
733 if fm.CLASSES[host["class"]].transport == "wsl":
734 flags += ["-e", "wsl_ci_host_state=absent"]
735 return flags
736
737
738def cmd_status(data: dict[str, Any], args: argparse.Namespace) -> int:
739 """Report what each runner host is actually running.
740
741 Read-only, and deliberately free of GitHub API calls: every probe is a
742 local one over ssh, so any number of agents can run it without touching the
743 shared REST quota.
744
745 Args:
746 data: The parsed declaration.
747 args: Parsed command line; uses ``args.host``.
748
749 Returns:
750 0 even when a host is unreachable -- an unreachable machine is
751 information, not a failure of the question.
752 """
753 names = [args.host] if args.host else list(data["hosts"])
754 for name in names:
755 host = _host(data, name)
756 if fm.CLASSES[host["class"]].capacity_kind == "none":
757 continue
758 print(f"{name} ({host['class']}, declared {host['runners']['instances']} instance(s)):")
759 # Flushed before handing the terminal to ssh, or Python's buffer holds
760 # the heading until after the rows it introduces have already printed.
761 sys.stdout.flush()
762 fcc.run(data, name, ["status"], _run)
763 return 0
764
765
766def cmd_reach(data: dict[str, Any], _args: argparse.Namespace) -> int:
767 """Probe every declared machine over its own transport.
768
769 The transport is the point: ``win-ci`` is not an ssh alias but a jump
770 through the bench Pi into a Windows box and then into a WSL distro, and a
771 reachability check that did not know that would report the fleet broken.
772 Because the list AND every address come from the declaration, a machine
773 added there is probed from the next run with nothing else edited, on a
774 control node with no ``~/.ssh/config`` at all.
775
776 Args:
777 data: The parsed declaration.
778 _args: Unused; the command takes no arguments.
779
780 Returns:
781 0 when every machine answered, 1 otherwise.
782 """
783 rc = 0
784 for name, host in data["hosts"].items():
785 # Captured, not streamed: the probe's own "ok" belongs to this function,
786 # not to the operator's terminal, and a failing host's ssh chatter would
787 # otherwise bury the one line that says which host failed.
788 probe = subprocess.run( # noqa: S603 -- argv built from the declaration
789 [*fr.ssh_target(data, name), fm.remote_shell(host)],
790 input="echo ok\n",
791 text=True,
792 capture_output=True,
793 check=False,
794 ).returncode
795 where = fr.ssh_destination(host)
796 if probe:
797 print(f" MISS {name:<10} not reachable at {where}")
798 rc = 1
799 else:
800 print(f" ok {name:<10} reachable at {where}")
801 return rc
802
803
804def cmd_capacity_quarantine(data: dict[str, Any], args: argparse.Namespace) -> int:
805 """Retain durable maintenance and drive one host to zero admission."""
806 return fcc.run(data, args.host, ["quarantine"], _run)
807
808
809def cmd_capacity_restore(data: dict[str, Any], args: argparse.Namespace) -> int:
810 """Restore the current quiet-hours target and clear durable maintenance."""
811 return fcc.run(data, args.host, ["restore"], _run)
812
813
814def cmd_scale(data: dict[str, Any], args: argparse.Namespace) -> int:
815 """Change live capacity, draining idle runners during shrink."""
816 return fcc.run(data, args.host, ["scale", str(args.count)], _run)
817
818
819def cmd_selftest(data: dict[str, Any], _args: argparse.Namespace) -> int:
820 """Run transport and typed-operation tests without contacting any host."""
821 failures = (
822 ftv.run_selftest()
823 + fw.run_selftest(data)
824 + fb.run_selftest()
825 + frm.run_selftest()
826 + fml.run_selftest()
827 + fcc.run_selftest(data)
828 + _bench_guard_inheritance_selftest()
829 + _inventory_publication_selftest()
830 + fm.controller_inventory_selftest(data)
831 + fb.parser_selftest(_parser)
832 )
833 if data["hosts"]["win-ci"]["class"] not in ftv.CONTAINER_RUNNER_CLASSES:
834 failures.append("declared WSL runner class was refused")
835 if data["hosts"]["dev"]["class"] in ftv.CONTAINER_RUNNER_CLASSES:
836 failures.append("non-container dev host was accepted as a container runner")
837 for failure in failures:
838 print(f"fleet.py --selftest: FAIL: {failure}", file=sys.stderr)
839 if failures:
840 return 1
841 print("fleet.py --selftest: PASS (typed schema, ownership/mode, quoting, redaction, cleanup)")
842 return 0
843
844
845def _add_converge_parsers(subs: _SubparserGroup) -> None:
846 """Add apply/check/remove parsers and their shared guarded arguments."""
847 for mode in ("check", "apply", "remove"):
848 sub = subs.add_parser(mode, help=f"{mode} a host against the declaration")
849 sub.add_argument("host")
850 sub.add_argument("play", nargs="?", help="one play instead of all of them")
851 sub.add_argument("--no-drain", action="store_true", help="do not drain before converging")
852 sub.add_argument(
853 "-e",
854 "--extra-var",
855 action="append",
856 default=[],
857 metavar="KEY=VALUE",
858 help=(
859 "pass a non-secret compatibility variable through to ansible; "
860 "credentials require register-runner, register-hil, or remove --vars-file"
861 ),
862 )
863 if mode == "remove":
864 sub.add_argument(
865 "--vars-file",
866 default="",
867 help="typed mode-0600 removal/dataset vars file",
868 )
869 sub.add_argument(
870 "--tags",
871 default="",
872 help="ansible tags; "
873 + "/".join(sorted(fm.NO_DRAIN_TAGS))
874 + " touch no container and so need no drain",
875 )
876
877
878def _parser() -> argparse.ArgumentParser:
879 """Build the command-line parser.
880
881 Returns:
882 A parser whose subcommands mirror the module docstring.
883 """
884 parser = argparse.ArgumentParser(prog="fleet.py", description=__doc__.splitlines()[0])
885 subs = parser.add_subparsers(dest="command", required=True)
886 subs.add_parser("selftest", help="exercise typed vars and WSL rendering offline")
887 subs.add_parser("list", help="what is declared, and how it is sized")
888 subs.add_parser("show", help="one host in full").add_argument("host")
889 subs.add_parser("validate", help="the fleet-declaration gate's check")
890 subs.add_parser("reach", help="probe every machine over its declared transport")
891 inv = subs.add_parser("inventory", help="write the Ansible inventory")
892 inv.add_argument("--stdout", action="store_true", help="print instead of writing")
893 ssh_config = subs.add_parser(
894 "ssh-config", help="the fleet's host aliases, generated from the declaration"
895 )
896 ssh_config.add_argument(
897 "--install",
898 action="store_true",
899 help=(
900 f"write ~/.ssh/{fr.SSH_FRAGMENT_NAME} and include it from ~/.ssh/config -- "
901 "the one command that makes this machine a control node"
902 ),
903 )
904 subs.add_parser(
905 "ssh-target", help="the ssh command that reaches one host from here"
906 ).add_argument("host")
907 register_runner = subs.add_parser(
908 "register-runner", help="first-register one declared Docker runner host"
909 )
910 register_runner.add_argument("host")
911 register_runner.add_argument("vars_file")
912 subs.add_parser(
913 "register-hil", help="first-register the one declared native HIL listener"
914 ).add_argument("vars_file")
915 _add_converge_parsers(subs)
916 parked_commands = {
917 "reconcile-parked-apply": ("apply", False),
918 "reconcile-parked-check": ("check", False),
919 "reconcile-activate": ("apply", True),
920 "reconcile-activation-check": ("check", True),
921 }
922 for command, (mode, no_drain) in parked_commands.items():
923 internal = subs.add_parser(command, help=argparse.SUPPRESS)
924 internal.add_argument("host")
925 internal.set_defaults(
926 mode=mode,
927 play=None,
928 no_drain=no_drain,
929 extra_var=[],
930 tags="",
931 vars_file="",
932 trusted_tags=False,
933 )
934 status = subs.add_parser("status", help="what each host is running, right now")
935 status.add_argument("host", nargs="?")
936 for command in ("capacity-quarantine", "capacity-restore"):
937 internal = subs.add_parser(command, help=argparse.SUPPRESS)
938 internal.add_argument("host")
939 scale = subs.add_parser("scale", help="live capacity change; shrinking drains")
940 scale.add_argument("host")
941 scale.add_argument("count", type=int)
942 return parser
943
944
945def main(argv: list[str] | None = None) -> int:
946 """Entry point.
947
948 Args:
949 argv: Command line, defaulting to ``sys.argv[1:]``.
950
951 Returns:
952 The chosen subcommand's exit status.
953 """
954 original_argv = list(argv if argv is not None else sys.argv[1:])
955 args = _parser().parse_args(original_argv)
956 args.original_argv = original_argv
957 handlers = {
958 "selftest": cmd_selftest,
959 "list": cmd_list,
960 "show": cmd_show,
961 "validate": cmd_validate,
962 "reach": cmd_reach,
963 "inventory": cmd_inventory,
964 "ssh-config": cmd_ssh_config,
965 "ssh-target": cmd_ssh_target,
966 "register-runner": cmd_register_runner,
967 "register-hil": cmd_register_hil,
968 "check": cmd_converge,
969 "apply": cmd_converge,
970 "reconcile-parked-apply": cmd_converge,
971 "reconcile-parked-check": cmd_converge,
972 "reconcile-activate": cmd_converge,
973 "reconcile-activation-check": cmd_converge,
974 "remove": cmd_converge,
975 "status": cmd_status,
976 "capacity-quarantine": cmd_capacity_quarantine,
977 "capacity-restore": cmd_capacity_restore,
978 "scale": cmd_scale,
979 }
980 if not hasattr(args, "mode"):
981 args.mode = args.command
982 try:
983 data = fm.load()
984 if args.command in MUTATING_COMMANDS:
985 if "RA8_FLEET_MUTATION_GUARDIAN_FD" not in os.environ:
986 return fml.run_locked(
987 data, [sys.executable, str(Path(__file__).resolve()), *original_argv]
988 )
989 fml.require_guardian_capability()
990 return handlers[args.command](data, args)
991 except (fml.MutationLockError, fm.FleetError) as exc:
992 return _fail(str(exc))
993
994
995if __name__ == "__main__":
996 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298