4"""How a control node REACHES the machines ``infra/fleet.yml`` declares.
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).
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:
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.
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.
33from __future__
import annotations
46SSH_FRAGMENT_NAME =
"ra8-fleet.config"
47SSH_INCLUDE_LINE = f
"Include {SSH_FRAGMENT_NAME}"
63 "StrictHostKeyChecking=accept-new",
67def ssh_destination(host: dict[str, Any]) -> str:
68 """The ``[user@]address`` a host is reached at, from anywhere.
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`
76 host: One host's declaration.
79 ``user@address`` when a login user is declared, ``address`` otherwise.
81 connect = host[
"connect"]
82 address = str(connect[
"address"])
83 user = connect.get(
"user")
84 return f
"{user}@{address}" if user
else address
87def jump_chain(data: dict[str, Any], name: str) -> list[str]:
88 """The ProxyJump hops that reach a host, outermost first.
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.
96 data: The parsed declaration.
97 name: Fleet host name.
100 One ``[user@]address`` per hop, empty when the host is reached direct.
102 hosts = data[
"hosts"]
103 chain: list[str] = []
105 cursor = (hosts[name].get(
"connect")
or {}).get(
"jump")
108 for _
in range(len(hosts)):
109 if not cursor
or cursor
in seen
or cursor
not in hosts:
111 seen.add(str(cursor))
112 chain.append(ssh_destination(hosts[cursor]))
113 cursor = (hosts[cursor].get(
"connect")
or {}).get(
"jump")
118def ssh_target(data: dict[str, Any], name: str) -> list[str]:
119 """The ``ssh`` argv prefix that reaches a host from ANY control node.
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.
127 data: The parsed declaration.
128 name: Fleet host name.
131 A complete ssh command up to but not including the remote command.
133 argv = [
"/usr/bin/ssh", *SSH_OPTIONS]
134 hops = jump_chain(data, name)
136 argv += [
"-J",
",".join(hops)]
137 argv.append(ssh_destination(data[
"hosts"][name]))
141def render_ssh_config(data: dict[str, Any]) -> str:
142 """Generate an SSH config fragment naming every declared machine.
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.
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
156 data: The parsed declaration.
159 An ``ssh_config(5)`` body, one ``Host`` block per declared machine.
162 "# GENERATED by scripts/dev/fleet.py from infra/fleet.yml -- do not edit.",
164 "# Install or refresh with:",
165 "# just infra::ssh_config",
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.",
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")
184 return "\n".join(lines)
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.
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
199 name: Fleet host name.
200 host: That host's declaration.
201 hosts: Every declared host, for resolving ``connect.jump``.
204 One message per violation.
206 connect = host.get(
"connect")
or {}
207 address = str(connect.get(
"address")
or "")
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."
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."
219 elif "." not in address
and ":" not in address:
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."
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])]
231 return bad + _check_jump(name, connect.get(
"jump"), hosts)
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.
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.
245 One message per violation.
249 if not isinstance(jump, str)
or jump
not in hosts:
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)}"
259 for _
in range(len(hosts) + 1):
264 f
"{name}: connect.jump chain revisits '{cursor}', so reaching this host "
265 "would need itself to already be reachable"
267 if cursor
not in hosts:
272 cursor = str((hosts[cursor].get(
"connect")
or {}).get(
"jump")
or "")
273 return [f
"{name}: connect.jump chain does not terminate"]