4"""Drive the CI fleet from ``infra/fleet.yml``.
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.
12from __future__
import annotations
19from collections.abc
import Callable, Mapping
20from dataclasses
import dataclass
21from pathlib
import Path
22from threading
import Event, Thread
23from typing
import Any, Protocol
25sys.path.insert(0, str(Path(__file__).resolve().parent))
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
37IDLE_STOP_HELPER = fm.REPO_ROOT /
"infra/ansible/roles/dev_box/files/ra8-hil-runner-idle-stop.py"
38MUTATING_COMMANDS = frozenset(
43 "reconcile-parked-apply",
44 "reconcile-parked-check",
47 "capacity-quarantine",
54class _SubparserGroup(Protocol):
55 """Expose the parser-factory operation used to build fleet subcommands."""
57 def add_parser(self, name: str, **kwargs: object) -> argparse.ArgumentParser:
58 """Create and return one named subparser."""
62def _fail(message: str) -> int:
63 """Print an error to stderr and give the caller a shell exit status.
66 message: What went wrong, in one line.
69 Always 2, the usage/precondition status this tool exits with.
71 print(f
"fleet: error: {message}", file=sys.stderr)
75def _host(data: dict[str, Any], name: str) -> dict[str, Any]:
76 """Look one host up in the declaration.
79 data: The parsed declaration.
80 name: Fleet host name.
86 FleetError: No host of that name is declared.
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]
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,
101 """Run a command, streaming its output, and return its status.
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
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.
115 The command's exit status.
117 options: dict[str, object] = {
119 "text":
not isinstance(stdin, bytes),
123 if subprocess_kwargs
is not None:
124 options.update(subprocess_kwargs)
125 proc = subprocess.run(
126 argv, check=
False, **options
128 return proc.returncode
131def cmd_list(data: dict[str, Any], _args: argparse.Namespace) -> int:
132 """Print every declared host with its class, capacity and schedule.
135 data: The parsed declaration.
136 _args: Unused; the command takes no arguments.
142 f
"{'HOST':<10} {'CLASS':<14} {'INSTANCES':<10} "
143 f
"{'PER INSTANCE':<18} {'QUIET HOURS':<32} PLAYS"
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 "-"
152 f
"{name:<10} {host['class']:<14} {count:<10} {per:<18} {window:<32} "
153 f
"{','.join(host['provisions'])}"
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")
164def cmd_show(data: dict[str, Any], args: argparse.Namespace) -> int:
165 """Print one host's declaration and everything derived from it.
168 data: The parsed declaration.
169 args: Parsed command line; uses ``args.host``.
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)
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")
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")
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']}"
203 print(
" derived ansible variables:")
204 for key, value
in sorted(fm.role_vars(data, args.host, host).items()):
205 print(f
" {key}: {value}")
209def cmd_validate(data: dict[str, Any], _args: argparse.Namespace) -> int:
210 """Report every rule the declaration breaks.
213 data: The parsed declaration.
214 _args: Unused; the command takes no arguments.
217 0 when the fleet is well declared, 1 otherwise.
219 problems = fm.validate(data)
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)
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"))
229 f
"infra/fleet.yml OK: {len(hosts)} host(s), {runners} capacity-managed "
230 f
"runner instance(s), {native_hil} native HIL listener(s)"
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)
242 with os.fdopen(fd,
"w", encoding=
"utf-8")
as stream:
245 os.fsync(stream.fileno())
246 temporary.replace(fm.INVENTORY)
247 directory = os.open(fm.INVENTORY.parent, os.O_RDONLY | os.O_DIRECTORY)
253 temporary.unlink(missing_ok=
True)
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")
266 partial: list[str] = []
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)
275 reader = Thread(target=read_generations)
278 for index
in range(32):
279 _publish_inventory(first
if index % 2
else second)
283 fm.INVENTORY = original
285 failures.append(
"concurrent inventory reader observed a partial generation")
289def cmd_inventory(data: dict[str, Any], args: argparse.Namespace) -> int:
290 """Generate the Ansible inventory from the declaration.
293 data: The parsed declaration.
294 args: Parsed command line; uses ``args.stdout``.
299 body = fm.render_inventory(data)
303 _publish_inventory(body)
304 print(f
"wrote {fm.inventory_label()} ({len(data['hosts'])} host(s))")
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)
313def cmd_ssh_target(data: dict[str, Any], args: argparse.Namespace) -> int:
314 """Print the ssh command that reaches one host from this machine.
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.
324 data: The parsed declaration.
325 args: Parsed command line; uses ``args.host``.
330 _host(data, args.host)
331 print(
" ".join(fr.ssh_target(data, args.host)))
335def _plays_for(host: dict[str, Any], only: str |
None) -> list[str]:
336 """Which plays an apply or check should run.
339 host: One host's declaration.
340 only: A single play the caller asked for, or None for all of them.
343 The plays in declared order, or empty when ``only`` is not one of them.
346 return list(host[
"provisions"])
347 return [only]
if only
in host[
"provisions"]
else []
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.
354 args: Parsed command line.
355 host: The target host's declaration.
356 plays: Plays the caller selected.
359 The refusal, or an empty string when the converge may proceed.
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):
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."
370 boundary = fb.control_flow_refusal(
377 bool(getattr(args,
"trusted_tags",
False)),
385def _restore_after_converge(data: dict[str, Any], args: argparse.Namespace, rc: int) -> int:
386 """Restore declared capacity after a drained converge, preserving failure."""
388 quarantine = fcc.run(data, args.host, [
"quarantine"], _run)
389 return rc
or quarantine
390 return fcc.run(data, args.host, [
"restore"], _run)
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"}
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"]
412 for pair
in args.extra_var:
413 extra += [
"-e", pair]
415 extra += [
"--tags", args.tags]
419def _registration_args(
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(
436 typed_vars=typed_vars,
438 original_argv=original_argv,
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:
447 f
"{args.host} is class {host['class']}; runner registration is limited to "
448 f
"{', '.join(sorted(ftv.CONTAINER_RUNNER_CLASSES))}"
450 provisions = list(host[
"provisions"])
451 if len(provisions) != 1
or provisions[0]
not in ftv.CONTAINER_RUNNER_PLAYS:
453 f
"{args.host} does not have one typed container-runner play: {', '.join(provisions)}"
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)
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)}")
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)
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",
"")
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):
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"
493 raise fm.FleetError(message)
497@dataclass(frozen=True)
498class _ConvergeTransport:
499 """Carry one validated converge transaction across its transport boundary."""
502 args: argparse.Namespace
506 typed_vars: ftv.TypedVars |
None
510def _bench_guard_argv(
511 host: dict[str, Any], plays: list[str], args: argparse.Namespace
513 """Build the outer whole-bench transaction before any side effect."""
515 return fb.guarded_argv(
518 Path(__file__).resolve(),
526 except ValueError
as exc:
527 raise fm.FleetError(str(exc))
from exc
530def _bench_ansible_extra(
531 host: dict[str, Any], plays: list[str], args: argparse.Namespace
533 """Carry the authenticated outer hold into the remote role."""
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
540def _runner_maintenance_request(
541 data: dict[str, Any],
542 host: dict[str, Any],
543 args: argparse.Namespace,
545) -> frm.MaintenanceRequest:
546 """Build the read-only preview and declared idle-stop transport."""
547 preview = frm.playbook_argv(
552 [
"--check",
"--diff", *extra],
554 remote =
"/usr/bin/sudo -n /usr/bin/python3 - ra8-hil-runner.service"
555 return frm.MaintenanceRequest(
560 [*fr.ssh_target(data, args.host), remote],
561 IDLE_STOP_HELPER.read_text(encoding=
"utf-8"),
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
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
579 return frm.prepare(maintenance)
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(
595 sync_image=request.args.mode ==
"apply" and not request.no_drain_tags,
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)
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
612def _bench_guard_inheritance_selftest() -> list[str]:
613 """Prove only mutating bench re-entry inherits the live guardian FD."""
614 failures: list[str] = []
616 expected = {
"env": {
"RA8_FLEET_MUTATION_GUARDIAN_FD":
"9"},
"pass_fds": (9,)}
618 def capability() -> dict[str, object]:
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")
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.
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.
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"
644 return _fail(refusal)
645 guard = _bench_guard_argv(host, plays, args)
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))
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
668 and not args.no_drain
669 and not no_drain_tags
670 and (parked
or fm.container_names(host))
673 print(f
"==> parking {args.host} before converging (a converge changes admission)")
677 rc = fcc.run(data, args.host, [
"maintenance-enter"], _run)
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:
685 rc = _restore_after_converge(data, args, rc)
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.
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).
699 0 on success, the first failing play's status otherwise.
701 host = data[
"hosts"][name]
703 argv = frm.playbook_argv(data, name, host, play, extra)
704 print(f
"==> ansible-playbook {fm.PLAYS[play].playbook} --limit {name}")
708 env=frm.ansible_environment(os.environ, fm.ANSIBLE_DIR),
715def _remove_flags(host: dict[str, Any], args: argparse.Namespace) -> list[str]:
716 """The extra-vars that turn a converge into a teardown.
719 host: One host's declaration.
720 args: Parsed command line.
723 The ``state=absent`` flags for a removal, empty otherwise.
725 if args.mode !=
"remove":
729 "ci_runner_docker_state=absent",
731 "fleet_capacity_enabled=false",
733 if fm.CLASSES[host[
"class"]].transport ==
"wsl":
734 flags += [
"-e",
"wsl_ci_host_state=absent"]
738def cmd_status(data: dict[str, Any], args: argparse.Namespace) -> int:
739 """Report what each runner host is actually running.
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
746 data: The parsed declaration.
747 args: Parsed command line; uses ``args.host``.
750 0 even when a host is unreachable -- an unreachable machine is
751 information, not a failure of the question.
753 names = [args.host]
if args.host
else list(data[
"hosts"])
755 host = _host(data, name)
756 if fm.CLASSES[host[
"class"]].capacity_kind ==
"none":
758 print(f
"{name} ({host['class']}, declared {host['runners']['instances']} instance(s)):")
762 fcc.run(data, name, [
"status"], _run)
766def cmd_reach(data: dict[str, Any], _args: argparse.Namespace) -> int:
767 """Probe every declared machine over its own transport.
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.
777 data: The parsed declaration.
778 _args: Unused; the command takes no arguments.
781 0 when every machine answered, 1 otherwise.
784 for name, host
in data[
"hosts"].items():
788 probe = subprocess.run(
789 [*fr.ssh_target(data, name), fm.remote_shell(host)],
795 where = fr.ssh_destination(host)
797 print(f
" MISS {name:<10} not reachable at {where}")
800 print(f
" ok {name:<10} reachable at {where}")
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)
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)
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)
819def cmd_selftest(data: dict[str, Any], _args: argparse.Namespace) -> int:
820 """Run transport and typed-operation tests without contacting any host."""
823 + fw.run_selftest(data)
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)
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)
841 print(
"fleet.py --selftest: PASS (typed schema, ownership/mode, quoting, redaction, cleanup)")
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")
859 "pass a non-secret compatibility variable through to ansible; "
860 "credentials require register-runner, register-hil, or remove --vars-file"
867 help=
"typed mode-0600 removal/dataset vars file",
872 help=
"ansible tags; "
873 +
"/".join(sorted(fm.NO_DRAIN_TAGS))
874 +
" touch no container and so need no drain",
878def _parser() -> argparse.ArgumentParser:
879 """Build the command-line parser.
882 A parser whose subcommands mirror the module docstring.
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"
896 ssh_config.add_argument(
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"
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"
910 register_runner.add_argument(
"host")
911 register_runner.add_argument(
"vars_file")
913 "register-hil", help=
"first-register the one declared native HIL listener"
914 ).add_argument(
"vars_file")
915 _add_converge_parsers(subs)
917 "reconcile-parked-apply": (
"apply",
False),
918 "reconcile-parked-check": (
"check",
False),
919 "reconcile-activate": (
"apply",
True),
920 "reconcile-activation-check": (
"check",
True),
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(
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)
945def main(argv: list[str] |
None =
None) -> int:
949 argv: Command line, defaulting to ``sys.argv[1:]``.
952 The chosen subcommand's exit status.
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
958 "selftest": cmd_selftest,
961 "validate": cmd_validate,
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,
980 if not hasattr(args,
"mode"):
981 args.mode = args.command
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]
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))
995if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.