ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
fleet_reach.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"""How a control node REACHES the machines ``infra/fleet.yml`` declares.
5
6One responsibility, and it is the one the fleet was missing. Every host used to
7be addressed by a bare ssh alias -- ``ssh: truenas``, ``ssh: star`` -- which
8resolves only through one machine's private ``~/.ssh/config``. The two things a
9control node needs were then split so that neither half had both: the Mac had
10the aliases and no ansible, the dev box had ansible and could resolve none of
11them, so ``fleet.py status truenas`` from the dev box died on
12*Could not resolve hostname truenas* while the machine answered fine on
13``10.10.10.1``. A naming gap, not a routing one -- and it cost a NAS running at
14half its declared capacity with nothing able to converge it back (#526).
15
16So a host declares an ADDRESS (an IP or a name a resolver can answer), a login
17``user``, and an optional ``jump`` naming another host in the same file. This
18module turns those three into everything that has to dial a machine:
19
20* the ``ssh`` argv every command in :mod:`fleet` uses -- literals only, so it
21 works on a machine whose ``~/.ssh/config`` is empty,
22* the ``ProxyJump`` chain, resolved through the declaration rather than through
23 an alias the hop happens to have somewhere,
24* the generated ``~/.ssh`` fragment, so the friendly names exist on any control
25 node by GENERATION rather than by hand-copying,
26* the rules that keep an alias from creeping back into the declaration.
27
28It is imported by :mod:`fleet_model`, and through it by both front doors -- the
29``fleet`` CLI and the ``fleet-declaration`` gate -- so there is still exactly
30one definition of how a machine is reached.
31"""
32
33from __future__ import annotations
34
35from typing import Any
36
37# The generated SSH config fragment, and the one line that pulls it in.
38#
39# Hand-writing the aliases onto every control node would have moved the same
40# per-machine prerequisite one level down and rotted the same way, so the
41# fragment is GENERATED from the declaration and installed with one command.
42#
43# Relative, not absolute: ssh_config(5) resolves an Include path without a
44# leading slash against ~/.ssh, so the same line works for every user on every
45# machine.
46SSH_FRAGMENT_NAME = "ra8-fleet.config"
47SSH_INCLUDE_LINE = f"Include {SSH_FRAGMENT_NAME}"
48
49# ssh options every fleet connection carries.
50#
51# BatchMode makes an unreachable host an error instead of a password prompt
52# that hangs an unattended converge. accept-new is trust-on-first-use: it pins
53# a host key the first time and still FAILS on a key that later changes, which
54# is what lets a fresh control node work without a hand-seeded known_hosts --
55# the last per-machine prerequisite after the aliases. It is strictly stronger
56# than the fleet's Ansible transport, which sets host_key_checking = False.
57SSH_OPTIONS = (
58 "-o",
59 "ConnectTimeout=15",
60 "-o",
61 "BatchMode=yes",
62 "-o",
63 "StrictHostKeyChecking=accept-new",
64)
65
66
67def ssh_destination(host: dict[str, Any]) -> str:
68 """The ``[user@]address`` a host is reached at, from anywhere.
69
70 The address is a literal -- an IP or a DNS name that resolves off the
71 machine's own resolver -- never an ``~/.ssh/config`` alias, which is a fact
72 about one laptop rather than about the fleet. :func:`check_connect`
73 enforces that.
74
75 Args:
76 host: One host's declaration.
77
78 Returns:
79 ``user@address`` when a login user is declared, ``address`` otherwise.
80 """
81 connect = host["connect"]
82 address = str(connect["address"])
83 user = connect.get("user")
84 return f"{user}@{address}" if user else address
85
86
87def jump_chain(data: dict[str, Any], name: str) -> list[str]:
88 """The ProxyJump hops that reach a host, outermost first.
89
90 ``connect.jump`` names another host IN THIS FLEET rather than an alias, so
91 a hop is resolved to a literal destination here exactly as the target is.
92 Hops chain: a host behind a host behind a bastion yields both, in the order
93 ``ssh -J`` connects to them.
94
95 Args:
96 data: The parsed declaration.
97 name: Fleet host name.
98
99 Returns:
100 One ``[user@]address`` per hop, empty when the host is reached direct.
101 """
102 hosts = data["hosts"]
103 chain: list[str] = []
104 seen = {name}
105 cursor = (hosts[name].get("connect") or {}).get("jump")
106 # Bounded by the fleet size, and a repeat means a cycle the validator
107 # reports: a loop here would hang every command that reaches a host.
108 for _ in range(len(hosts)):
109 if not cursor or cursor in seen or cursor not in hosts:
110 break
111 seen.add(str(cursor))
112 chain.append(ssh_destination(hosts[cursor]))
113 cursor = (hosts[cursor].get("connect") or {}).get("jump")
114 chain.reverse()
115 return chain
116
117
118def ssh_target(data: dict[str, Any], name: str) -> list[str]:
119 """The ``ssh`` argv prefix that reaches a host from ANY control node.
120
121 Every element comes from the declaration, so this command works on a
122 machine whose ``~/.ssh/config`` is empty. That is the whole point: the
123 generated fragment (:func:`render_ssh_config`) is a convenience for people
124 typing ``ssh truenas``, and nothing in this tooling depends on it.
125
126 Args:
127 data: The parsed declaration.
128 name: Fleet host name.
129
130 Returns:
131 A complete ssh command up to but not including the remote command.
132 """
133 argv = ["/usr/bin/ssh", *SSH_OPTIONS]
134 hops = jump_chain(data, name)
135 if hops:
136 argv += ["-J", ",".join(hops)]
137 argv.append(ssh_destination(data["hosts"][name]))
138 return argv
139
140
141def render_ssh_config(data: dict[str, Any]) -> str:
142 """Generate an SSH config fragment naming every declared machine.
143
144 This is what turns any machine into a control node with one command
145 instead of a hand-copied ``~/.ssh/config``. It is deliberately NOT what
146 this tooling reads: :func:`ssh_target` passes literal addresses, so the
147 fragment is a convenience for a person typing ``ssh truenas`` -- and for
148 the scripts and docs that already spell a host that way -- rather than a
149 prerequisite anything can be broken by omitting.
150
151 ``ProxyJump`` names the fleet host rather than its address, because the
152 fragment defines that host too and ssh resolves the hop through it. One
153 address, one place.
154
155 Args:
156 data: The parsed declaration.
157
158 Returns:
159 An ``ssh_config(5)`` body, one ``Host`` block per declared machine.
160 """
161 lines = [
162 "# GENERATED by scripts/dev/fleet.py from infra/fleet.yml -- do not edit.",
163 "#",
164 "# Install or refresh with:",
165 "# just infra::ssh_config",
166 "#",
167 "# Every address here comes from the declaration, so a machine added there",
168 "# is reachable by name from the next run of that command with nothing",
169 "# hand-edited. StrictHostKeyChecking accept-new pins a key on first use",
170 "# and still refuses one that later CHANGES.",
171 "",
172 ]
173 for name, host in data["hosts"].items():
174 connect = host["connect"]
175 lines.append(f"# {host.get('summary', name)}")
176 lines.append(f"Host {name}")
177 lines.append(f" HostName {connect['address']}")
178 if connect.get("user"):
179 lines.append(f" User {connect['user']}")
180 if connect.get("jump"):
181 lines.append(f" ProxyJump {connect['jump']}")
182 lines.append(" StrictHostKeyChecking accept-new")
183 lines.append("")
184 return "\n".join(lines)
185
186
187def check_connect(name: str, host: dict[str, Any], hosts: dict[str, Any]) -> list[str]:
188 """Rule: a host declares an address any machine could reach it at.
189
190 This is the rule the fleet was missing, and it cost real work (#526). An
191 address is required to be a LITERAL: an IP, or a name with a dot in it that
192 a resolver can answer. A bare label is exactly the defect and is rejected
193 by name, so it cannot come back the next time a machine is added. The login
194 user is its own field rather than a ``user@`` prefix, and a jump names
195 another host IN THIS FLEET, so a hop is declared once and resolved the same
196 way its target is.
197
198 Args:
199 name: Fleet host name.
200 host: That host's declaration.
201 hosts: Every declared host, for resolving ``connect.jump``.
202
203 Returns:
204 One message per violation.
205 """
206 connect = host.get("connect") or {}
207 address = str(connect.get("address") or "")
208 if not address:
209 return [
210 f"{name}: connect.address is required -- an IP or a resolvable DNS name. "
211 "Nothing can reach a machine that only one laptop's ~/.ssh/config knows about."
212 ]
213 bad = []
214 if "@" in address:
215 bad.append(
216 f"{name}: connect.address '{address}' carries a login user. Put the user in "
217 "connect.user; the address is the machine, not the account."
218 )
219 elif "." not in address and ":" not in address:
220 bad.append(
221 f"{name}: connect.address '{address}' is a bare label, which is an "
222 "~/.ssh/config alias rather than an address -- it resolves on whichever "
223 "machine happens to define it and nowhere else (#526). Declare the IP or a "
224 "fully qualified name; `fleet.py ssh-config` generates the alias FROM it."
225 )
226 bad += [
227 f"{name}: connect.{key} '{connect[key]}' contains whitespace"
228 for key in ("address", "user", "jump")
229 if connect.get(key) and str(connect[key]).split() != [str(connect[key])]
230 ]
231 return bad + _check_jump(name, connect.get("jump"), hosts)
232
233
234def _check_jump(name: str, jump: object, hosts: dict[str, Any]) -> list[str]:
235 """Rule: a ProxyJump hop is another declared host, and the chain terminates.
236
237 Args:
238 name: Fleet host name.
239 jump: That host's ``connect.jump``, or None. Deliberately ``object``:
240 a declaration may put anything here, and a checker that raised on
241 a wrong type would teach nothing.
242 hosts: Every declared host.
243
244 Returns:
245 One message per violation.
246 """
247 if not jump:
248 return []
249 if not isinstance(jump, str) or jump not in hosts:
250 return [
251 f"{name}: connect.jump '{jump}' is not a declared host. A hop is a fleet "
252 f"host, not an ssh alias -- declared: {', '.join(hosts)}"
253 ]
254 seen = {name}
255 cursor = jump
256 # Bounded by the fleet size: one more hop than there are hosts can only
257 # mean the chain revisits one, and an unbounded walk would hang every
258 # command that reaches this host rather than reporting the cycle.
259 for _ in range(len(hosts) + 1):
260 if not cursor:
261 return []
262 if cursor in seen:
263 return [
264 f"{name}: connect.jump chain revisits '{cursor}', so reaching this host "
265 "would need itself to already be reachable"
266 ]
267 if cursor not in hosts:
268 # Reported against the host that names it, not against this one:
269 # that host's own check_connect call covers it.
270 return []
271 seen.add(cursor)
272 cursor = str((hosts[cursor].get("connect") or {}).get("jump") or "")
273 return [f"{name}: connect.jump chain does not terminate"]