@crvouga/dev-fleet 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +88 -7
  2. package/agents/audit.md +18 -18
  3. package/ansible/agents/agents.yml +134 -0
  4. package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
  5. package/ansible/agents/templates/claude.settings.json.j2 +3 -0
  6. package/ansible/agents/templates/codex.config.toml.j2 +11 -0
  7. package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
  8. package/ansible/agents/templates/grok.config.toml.j2 +19 -0
  9. package/ansible/agents/templates/opencode.json.j2 +24 -0
  10. package/ansible/agents/templates/receipt.json.j2 +10 -0
  11. package/ansible/ansible.cfg +14 -0
  12. package/ansible/github/git.yml +82 -0
  13. package/ansible/macos/manager-privilege.yml +83 -0
  14. package/ansible/macos/manager.yml +164 -9
  15. package/ansible/macos/policy.json +13 -0
  16. package/ansible/macos/t3-policy.yml +5 -3
  17. package/ansible/macos/vault-renew.yml +85 -0
  18. package/ansible/macos/worker-host-key.yml +33 -0
  19. package/ansible/monitoring/agent-darwin.yml +3 -0
  20. package/ansible/monitoring/agent.yml +2 -0
  21. package/ansible/monitoring/hub-darwin.yml +3 -0
  22. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  23. package/ansible/net/remote-probe.sh +4 -1
  24. package/ansible/platforms.json +2 -2
  25. package/ansible/pyproject.toml +5 -0
  26. package/ansible/ubuntu/baseline.yml +39 -13
  27. package/ansible/ubuntu/disk-gc.py +6 -2
  28. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  29. package/ansible/ubuntu/policy.json +125 -6
  30. package/ansible/ubuntu/reboot.yml +89 -0
  31. package/ansible/ubuntu/recovery.yml +13 -3
  32. package/ansible/ubuntu/t3/policy.json +3 -0
  33. package/ansible/ubuntu/t3-desktop.yml +23 -2
  34. package/ansible/ubuntu/t3-headless.yml +128 -0
  35. package/ansible/ubuntu/t3-health.sh +24 -10
  36. package/ansible/ubuntu/t3-idle.py +37 -2
  37. package/ansible/ubuntu/t3-policy.yml +4 -1
  38. package/ansible/ubuntu/t3-route.yml +1 -0
  39. package/ansible/ubuntu/tailscale.yml +54 -8
  40. package/ansible/ubuntu/tasks/browser.yml +3 -0
  41. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  42. package/ansible/ubuntu/tasks/docker.yml +53 -6
  43. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  44. package/ansible/ubuntu/tasks/features.yml +22 -0
  45. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  46. package/ansible/ubuntu/tasks/install.yml +2 -1
  47. package/ansible/ubuntu/tasks/packages.yml +4 -2
  48. package/ansible/ubuntu/tasks/release.yml +6 -0
  49. package/ansible/ubuntu/tasks/resources.yml +6 -4
  50. package/ansible/ubuntu/tasks/services.yml +2 -2
  51. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  52. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  53. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  54. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  55. package/ansible/ubuntu/tasks/user.yml +1 -0
  56. package/ansible/ubuntu/worker-account.yml +117 -0
  57. package/ansible/uv.lock +265 -0
  58. package/bin/devfleet.mjs +42 -4
  59. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  60. package/bootstrap/global-config.ts +29 -3
  61. package/bootstrap/package-smoke.ts +127 -0
  62. package/bootstrap/pins.ts +189 -0
  63. package/bootstrap/release-notes.ts +88 -0
  64. package/bootstrap/release-version.ts +41 -7
  65. package/bootstrap/worker-features.ts +114 -0
  66. package/bootstrap/worker-seed.yaml +65 -0
  67. package/bootstrap/worker-tools.ts +39 -9
  68. package/cliproxy/README.md +1 -1
  69. package/cliproxy/docs/health-workflow.example.yml +20 -143
  70. package/docs/authentication.md +21 -11
  71. package/docs/consumer-setup.md +74 -58
  72. package/docs/convergence.md +67 -0
  73. package/docs/declared.md +61 -34
  74. package/docs/machine-setup.md +73 -7
  75. package/docs/manager-setup.md +34 -0
  76. package/docs/operations.md +64 -9
  77. package/docs/releasing.md +49 -1
  78. package/docs/runbooks.md +112 -7
  79. package/docs/state.md +23 -11
  80. package/docs/support-matrix.md +40 -4
  81. package/docs/topology.md +82 -12
  82. package/docs/ubuntu-sync.md +98 -42
  83. package/docs/worker-contract.md +122 -19
  84. package/docs/worker-requirements.md +107 -0
  85. package/examples/fleet.json +1 -0
  86. package/examples/workers-from-infrastructure/main.tf +132 -0
  87. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  88. package/llms.txt +3 -1
  89. package/mise.lock +29 -0
  90. package/mise.toml +5 -3
  91. package/package.json +2 -2
  92. package/schema/check-ids.json +1991 -162
  93. package/schema/check-ids.schema.json +34 -0
  94. package/schema/fleet.schema.json +349 -10
  95. package/schema/output/check.schema.json +1 -1
  96. package/schema/output/fleet-state-definition.schema.json +14 -0
  97. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  98. package/schema/output/fleet-sync.schema.json +18 -0
  99. package/schema/output/report.schema.json +3 -2
  100. package/schema/worker-record.schema.json +76 -0
  101. package/src/agents/converge.ts +140 -24
  102. package/src/agents/files.ts +2 -32
  103. package/src/agents/native-verify.ts +34 -3
  104. package/src/agents/secrets.ts +39 -4
  105. package/src/check-owners.ts +62 -2
  106. package/src/check-registry.ts +630 -0
  107. package/src/cli/agent-guide.ts +3 -3
  108. package/src/cli/cli.ts +4 -0
  109. package/src/cli/fleet-manifest.ts +108 -0
  110. package/src/cli/fleet-node-lifecycle.ts +704 -0
  111. package/src/cli/fleet-node-park.ts +238 -0
  112. package/src/cli/fleet-node-seed.ts +167 -0
  113. package/src/cli/fleet-state.ts +2 -1
  114. package/src/cli/fleet-sync-checkout.ts +7 -0
  115. package/src/cli/fleet-sync-enrollment.ts +193 -32
  116. package/src/cli/fleet-sync-forward.ts +6 -0
  117. package/src/cli/fleet-sync-manager.ts +123 -8
  118. package/src/cli/fleet-sync-output.ts +31 -11
  119. package/src/cli/fleet-sync-plan.ts +65 -9
  120. package/src/cli/fleet-sync-records.ts +113 -0
  121. package/src/cli/fleet-sync-remote.ts +172 -15
  122. package/src/cli/fleet-sync-schedule.ts +14 -4
  123. package/src/cli/fleet-sync-state.ts +31 -10
  124. package/src/cli/fleet-sync-vault.ts +152 -26
  125. package/src/cli/fleet-sync-worker.ts +214 -0
  126. package/src/cli/fleet-sync.ts +172 -21
  127. package/src/cli/fleet-validate.ts +277 -0
  128. package/src/cli/fleet-watch.ts +7 -1
  129. package/src/cli/fleet.ts +87 -5
  130. package/src/cli/managed-health.ts +25 -1
  131. package/src/cli/register-fleet.ts +251 -4
  132. package/src/cli/registry.ts +47 -0
  133. package/src/cli/tailnet.ts +43 -10
  134. package/src/cli/ui-data.ts +3 -1
  135. package/src/cli/worker-usage-report.ts +63 -0
  136. package/src/clock.ts +42 -0
  137. package/src/definition.ts +762 -232
  138. package/src/diagnostics.ts +5 -2
  139. package/src/exposure.ts +102 -0
  140. package/src/fleet-config.ts +3 -1
  141. package/src/git-identity.ts +76 -3
  142. package/src/ide/ide.ts +5 -0
  143. package/src/ide/t3.ts +12 -2
  144. package/src/input-hashes.ts +6 -0
  145. package/src/machine/machine-class.ts +59 -0
  146. package/src/machine/platform.ts +90 -11
  147. package/src/machine/state.ts +70 -16
  148. package/src/machine/support-matrix.ts +11 -9
  149. package/src/machine-health.ts +28 -5
  150. package/src/manager-health.ts +148 -2
  151. package/src/manager-sync.ts +89 -8
  152. package/src/monitoring/beszel.ts +8 -8
  153. package/src/monitoring/enroll.ts +8 -1
  154. package/src/monitoring/hub-access.ts +10 -4
  155. package/src/monitoring/systems.ts +138 -0
  156. package/src/network-definition.ts +13 -4
  157. package/src/node-converge-linux.ts +136 -27
  158. package/src/node-converge.ts +13 -3
  159. package/src/platform/ansible-receipt.ts +17 -2
  160. package/src/platform/ansible.ts +40 -4
  161. package/src/platform/manager-host.ts +25 -8
  162. package/src/platform/resource-verify.ts +18 -7
  163. package/src/platform/t3-policy.ts +24 -10
  164. package/src/platform/t3-service.ts +109 -34
  165. package/src/platform/ubuntu-release.ts +24 -0
  166. package/src/platform/ubuntu-verify.ts +31 -3
  167. package/src/platform/ubuntu-worker.ts +58 -0
  168. package/src/platform/worker-platform.ts +72 -0
  169. package/src/protocol.ts +1 -1
  170. package/src/public-reachability.ts +186 -0
  171. package/src/remote-bootstrap.ts +50 -12
  172. package/src/remote-operation.ts +18 -4
  173. package/src/repair-plan.ts +23 -12
  174. package/src/report.ts +2 -1
  175. package/src/schedule-check.ts +21 -2
  176. package/src/secrets.ts +7 -0
  177. package/src/service-auth.ts +55 -48
  178. package/src/ssh-connection.ts +141 -0
  179. package/src/ssh.ts +63 -24
  180. package/src/state/store.ts +4 -2
  181. package/src/state/sync.ts +35 -6
  182. package/src/state/workers.ts +140 -0
  183. package/src/t3/pairing.ts +59 -0
  184. package/src/t3/peer-link.ts +333 -0
  185. package/src/t3/version.ts +1 -1
  186. package/src/tailnet-cleanup.ts +185 -0
  187. package/src/tailnet-control.ts +346 -0
  188. package/src/tailscale.ts +1 -1
  189. package/src/toolchain-checks.ts +11 -1
  190. package/src/types.ts +4 -2
  191. package/src/worker-account.ts +63 -0
  192. package/src/worker-activity.ts +303 -0
  193. package/src/worker-capacity.ts +31 -0
  194. package/src/worker-credentials.ts +201 -0
  195. package/src/worker-exposure.ts +388 -0
  196. package/src/worker-features.ts +201 -0
  197. package/src/worker-host-checks.ts +71 -20
  198. package/src/worker-manifest.ts +224 -0
  199. package/src/worker-parking.ts +70 -0
  200. package/src/worker-policy.ts +38 -36
  201. package/src/worker-readiness.ts +15 -3
  202. package/src/worker-reboot.ts +129 -0
  203. package/src/worker-records.ts +305 -0
  204. package/src/worker-seed.ts +143 -0
  205. package/src/worker-sizing.ts +76 -0
  206. package/src/cli/schedule-logs.ts +0 -36
@@ -0,0 +1,333 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { record } from '../config';
5
+ import { sshArgs } from '../ssh';
6
+ import { observedIdentity } from '../t3-linked-check';
7
+ import { discoverTailnet } from '../tailscale';
8
+ import type { Check, FleetNode, Runner } from '../types';
9
+
10
+ /** Pairing the manager's T3 with a worker's T3 environment, with no person.
11
+ *
12
+ * A pairing code is a one-time secret, so it never enters a command line,
13
+ * a report or a log. The worker mints it (`t3 auth pairing create --json`
14
+ * over the SSH session the manager already has), and its stdout is read in
15
+ * memory. The manager's T3 receives it in the body of one request to its
16
+ * own server on loopback: the `peerLinks.link` call of its WebSocket API,
17
+ * which the T3 Settings (Connections) use, authorized by a session issued
18
+ * for this one call and revoked after it. `t3 environment link` takes the
19
+ * code only as `--pairing-code`, so it is not used.
20
+ */
21
+
22
+ /** What the manager's T3 is asked to do: link the environment at `url`. */
23
+ export interface LinkRequest {
24
+ url: string;
25
+ pairingCode: string;
26
+ /** The environment the worker answered as over SSH: T3 refuses an
27
+ * address that answers as another one. */
28
+ expectedEnvironmentId: string;
29
+ }
30
+
31
+ export type LinkOutcome =
32
+ | { environmentId: string }
33
+ /** Why T3 refused, as its own reason (`pairing_rejected`, `unreachable`,
34
+ * `disabled`, …), or `unavailable` when the manager's T3 did not answer. */
35
+ | { refused: string };
36
+
37
+ /** The manager's own T3. */
38
+ export interface ManagerT3 {
39
+ link(request: LinkRequest): Promise<LinkOutcome>;
40
+ }
41
+
42
+ /** The two requests to the manager's T3 server, injected by scenarios. */
43
+ export interface T3Transport {
44
+ /** POSTs to the server with the session as a bearer token; the JSON body. */
45
+ post(url: string, bearer: string): Promise<unknown>;
46
+ /** Sends one message on a WebSocket and answers with the first message
47
+ * that ends request `id`. */
48
+ call(url: string, message: object, id: string): Promise<unknown>;
49
+ }
50
+
51
+ /** The access the manager's agents get on the worker: T3's default. */
52
+ const ACCESS = 'approval-required';
53
+ /** Scopes of the session that makes the call, and how long it lives at most. */
54
+ const SESSION = [
55
+ '--ttl',
56
+ '2m',
57
+ '--scope',
58
+ 'access:write',
59
+ '--label',
60
+ 'dev-fleet pairing',
61
+ ];
62
+ /** The WebSocket protocol of the pinned T3 server. */
63
+ const ORCHESTRATION_PROTOCOL = '2';
64
+ const CODE = /^[A-Za-z0-9_-]{6,128}$/;
65
+ const ENVIRONMENT =
66
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
67
+
68
+ /** The manager's T3, reached through its local server. */
69
+ export function managerT3(
70
+ run: Runner,
71
+ options: {
72
+ home?: string;
73
+ transport?: T3Transport;
74
+ read?: (path: string) => Promise<string>;
75
+ } = {}
76
+ ): ManagerT3 {
77
+ const home = resolve(
78
+ options.home ?? process.env['T3CODE_HOME'] ?? join(homedir(), '.t3')
79
+ );
80
+ const transport = options.transport ?? webTransport;
81
+ const read = options.read ?? ((path: string) => readFile(path, 'utf8'));
82
+ return {
83
+ async link(request) {
84
+ const origin = await loopbackOrigin(read, home);
85
+ if (!origin) return { refused: 'unavailable' };
86
+ const issued = await run(
87
+ ['t3', 'auth', 'session', 'issue', '--json', ...SESSION],
88
+ { timeoutMs: 30_000 }
89
+ );
90
+ const session = parseSession(issued.ok ? issued.stdout : '');
91
+ if (!session) return { refused: 'unavailable' };
92
+ try {
93
+ const ticket = await transport.post(
94
+ `${origin}/api/auth/websocket-ticket/rpc`,
95
+ session.token
96
+ );
97
+ if (!record(ticket) || typeof ticket['ticket'] !== 'string')
98
+ return { refused: 'unavailable' };
99
+ const socket = new URL('/ws', origin);
100
+ socket.protocol = socket.protocol === 'https:' ? 'wss:' : 'ws:';
101
+ socket.searchParams.set(
102
+ 'orchestrationProtocol',
103
+ ORCHESTRATION_PROTOCOL
104
+ );
105
+ socket.searchParams.set('wsTicket', ticket['ticket']);
106
+ const reply = await transport.call(
107
+ socket.href,
108
+ {
109
+ _tag: 'Request',
110
+ id: '1',
111
+ tag: 'peerLinks.link',
112
+ payload: {
113
+ url: request.url,
114
+ pairingCode: request.pairingCode,
115
+ access: ACCESS,
116
+ expectedEnvironmentId: request.expectedEnvironmentId,
117
+ },
118
+ headers: [],
119
+ },
120
+ '1'
121
+ );
122
+ return outcomeOf(reply);
123
+ } catch {
124
+ return { refused: 'unavailable' };
125
+ } finally {
126
+ await run(['t3', 'auth', 'session', 'revoke', session.id], {
127
+ timeoutMs: 30_000,
128
+ });
129
+ }
130
+ },
131
+ };
132
+ }
133
+
134
+ /** The origin the manager's T3 server records for itself, when it is on
135
+ * this machine: a code is never sent anywhere else. */
136
+ async function loopbackOrigin(
137
+ read: (path: string) => Promise<string>,
138
+ home: string
139
+ ): Promise<string | undefined> {
140
+ try {
141
+ const runtime: unknown = JSON.parse(
142
+ await read(join(home, 'userdata/server-runtime.json'))
143
+ );
144
+ if (!record(runtime) || typeof runtime['origin'] !== 'string') return;
145
+ const origin = new URL(runtime['origin']);
146
+ return origin.protocol === 'http:' &&
147
+ ['127.0.0.1', 'localhost', '[::1]'].includes(origin.hostname)
148
+ ? origin.origin
149
+ : undefined;
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ function parseSession(text: string): { id: string; token: string } | null {
156
+ try {
157
+ const value: unknown = JSON.parse(text);
158
+ return record(value) &&
159
+ typeof value['sessionId'] === 'string' &&
160
+ /^[A-Za-z0-9_-]{1,128}$/.test(value['sessionId']) &&
161
+ typeof value['token'] === 'string' &&
162
+ value['token'].length > 0
163
+ ? { id: value['sessionId'], token: value['token'] }
164
+ : null;
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /** The end of the call: the link T3 made, or why it refused. */
171
+ function outcomeOf(reply: unknown): LinkOutcome {
172
+ if (!record(reply) || reply['_tag'] !== 'Exit' || !record(reply['exit']))
173
+ return { refused: 'unavailable' };
174
+ const exit = reply['exit'];
175
+ if (exit['_tag'] === 'Success' && record(exit['value'])) {
176
+ const id = exit['value']['environmentId'];
177
+ return typeof id === 'string' && ENVIRONMENT.test(id)
178
+ ? { environmentId: id }
179
+ : { refused: 'unavailable' };
180
+ }
181
+ return { refused: refusal(exit['cause']) };
182
+ }
183
+
184
+ /** T3's own reason for a failed call: a word, never its message. */
185
+ function refusal(cause: unknown): string {
186
+ const first: unknown = Array.isArray(cause) ? cause[0] : undefined;
187
+ const error = record(first) ? first['error'] : undefined;
188
+ const reason = record(error) ? error['reason'] : undefined;
189
+ return typeof reason === 'string' && /^[a-z_]{1,40}$/.test(reason)
190
+ ? reason
191
+ : 'unavailable';
192
+ }
193
+
194
+ const webTransport: T3Transport = {
195
+ async post(url, bearer) {
196
+ const response = await fetch(url, {
197
+ method: 'POST',
198
+ headers: { authorization: `Bearer ${bearer}` },
199
+ signal: AbortSignal.timeout(10_000),
200
+ });
201
+ return response.ok ? ((await response.json()) as unknown) : null;
202
+ },
203
+ call(url, message, id) {
204
+ return new Promise((resolve, reject) => {
205
+ const socket = new WebSocket(url);
206
+ const timer = setTimeout(() => {
207
+ socket.close();
208
+ reject(new Error('timeout'));
209
+ }, 60_000);
210
+ const end = (value: unknown, error?: Error) => {
211
+ clearTimeout(timer);
212
+ socket.close();
213
+ if (error) reject(error);
214
+ else resolve(value);
215
+ };
216
+ socket.onopen = () => socket.send(JSON.stringify(message));
217
+ socket.onmessage = (event) => {
218
+ try {
219
+ const reply: unknown = JSON.parse(String(event.data));
220
+ if (
221
+ record(reply) &&
222
+ reply['_tag'] === 'Exit' &&
223
+ reply['requestId'] === id
224
+ )
225
+ end(reply);
226
+ } catch {
227
+ // Not the reply; keep waiting.
228
+ }
229
+ };
230
+ socket.onerror = () => end(null, new Error('socket'));
231
+ socket.onclose = () => end(null, new Error('closed'));
232
+ });
233
+ },
234
+ };
235
+
236
+ /** Run on the worker, as its development user, over SSH: mints a pairing
237
+ * code for the manager with the worker's own T3 (the CLI on PATH, or the
238
+ * pinned one the toolchain installed) and prints it as JSON on stdout. */
239
+ export const MINT_PAIRING_CODE =
240
+ 't3="$(command -v t3 || ls -d "$HOME"/.local/share/personal/tools/t3-*/t3 2>/dev/null | sort -V | tail -n 1)" && ' +
241
+ 'exec "$t3" auth pairing create --json --ttl 5m --label "dev-fleet manager"';
242
+
243
+ /** Mints a one-time pairing code on the worker. The code stays in memory. */
244
+ async function mintPairingCode(
245
+ node: FleetNode,
246
+ run: Runner
247
+ ): Promise<string | undefined> {
248
+ const minted = await run([...sshArgs(node), MINT_PAIRING_CODE], {
249
+ timeoutMs: 30_000,
250
+ });
251
+ if (!minted.ok) return undefined;
252
+ try {
253
+ const value: unknown = JSON.parse(minted.stdout);
254
+ return record(value) &&
255
+ typeof value['credential'] === 'string' &&
256
+ CODE.test(value['credential'])
257
+ ? value['credential']
258
+ : undefined;
259
+ } catch {
260
+ return undefined;
261
+ }
262
+ }
263
+
264
+ export type Pairing =
265
+ | { paired: string }
266
+ /** The worker's T3 does not answer yet: nothing was minted. */
267
+ | { waiting: true }
268
+ | { failed: string };
269
+
270
+ /** Pairs the manager's T3 with the T3 environment `node` runs now: the
271
+ * environment the worker answers as over SSH, at its private HTTPS address
272
+ * on the tailnet. */
273
+ export async function pairWorker(
274
+ node: FleetNode,
275
+ run: Runner,
276
+ manager: ManagerT3
277
+ ): Promise<Pairing> {
278
+ const identity = await observedIdentity(node, run).catch(() => undefined);
279
+ if (!identity) return { waiting: true };
280
+ const tailnet = await discoverTailnet(run);
281
+ const peer = tailnet.computers.find(
282
+ (computer) => computer.id === node.tailscaleId && computer.online
283
+ );
284
+ if (!peer || !/^[a-z0-9.-]+\.ts\.net$/.test(peer.dns))
285
+ return { failed: 'unreachable' };
286
+ const pairingCode = await mintPairingCode(node, run);
287
+ if (!pairingCode) return { failed: 'mint' };
288
+ const linked = await manager.link({
289
+ url: `https://${peer.dns}`,
290
+ pairingCode,
291
+ expectedEnvironmentId: identity,
292
+ });
293
+ if ('refused' in linked) return { failed: linked.refused };
294
+ return linked.environmentId === identity
295
+ ? { paired: identity }
296
+ : { failed: 'identity' };
297
+ }
298
+
299
+ /** What each refusal asks a person to do. */
300
+ function remedy(id: string, reason: string): string {
301
+ switch (reason) {
302
+ case 'mint':
303
+ return `The T3 of ${id} did not mint a pairing code. Run devfleet fleet-sync --node ${id} so it runs the pinned T3, then run the command again.`;
304
+ case 'disabled':
305
+ return "Linked environments is off in the manager's T3. Turn it on in its Settings (Labs), or stage the fleet's T3 policy, which turns it on, with ansible/macos/manager-privilege.yml as an administrator; then run the command again.";
306
+ case 'unavailable':
307
+ return "The manager's T3 did not answer on this machine. Open the T3 app (or start its service), then run the command again.";
308
+ default:
309
+ return `The T3 of ${id} refused the link (${reason}). Check that it runs the T3 policy fleet-sync stages (Linked environments on) and answers at its private address, then run devfleet fleet-sync --node ${id}.`;
310
+ }
311
+ }
312
+
313
+ /** The `t3:link` check of a pairing. */
314
+ export function pairingCheck(id: string, pairing: Pairing): Check {
315
+ if ('paired' in pairing)
316
+ return {
317
+ id: 't3:link',
318
+ status: 'pass',
319
+ summary: `The manager's T3 is paired with the T3 environment ${pairing.paired} of ${id}.`,
320
+ };
321
+ if ('waiting' in pairing)
322
+ return {
323
+ id: 't3:link',
324
+ status: 'skip',
325
+ summary: `The T3 of ${id} does not answer yet; devfleet fleet-sync --node ${id} pairs the manager with it once it runs.`,
326
+ };
327
+ return {
328
+ id: 't3:link',
329
+ status: 'fail',
330
+ summary: 'T3_LINK_PAIRING_FAILED',
331
+ remedy: { action: remedy(id, pairing.failed) },
332
+ };
333
+ }
package/src/t3/version.ts CHANGED
@@ -62,7 +62,7 @@ export async function managerVersion(
62
62
  running,
63
63
  await pinnedT3Version(workspace),
64
64
  'the manager',
65
- 'Install that release of T3 Code on the manager. With the fleet policy staged (ansible-playbook -K ansible/macos/manager.yml) it no longer updates itself.'
65
+ 'Install that release of T3 Code on the manager. With the fleet policy staged (ansible-playbook -e user=$(id -un) -K ansible/macos/manager-privilege.yml) it no longer updates itself.'
66
66
  );
67
67
  }
68
68
 
@@ -0,0 +1,185 @@
1
+ import type { FleetDefinition, MachineDefinition } from './definition';
2
+ import type { SecretsProvider } from './secrets';
3
+ import {
4
+ TailnetControlError,
5
+ controlRemedy,
6
+ fleetTailnetControl,
7
+ type ControlHttp,
8
+ type TailnetConfig,
9
+ type TailnetControl,
10
+ } from './tailnet-control';
11
+ import type { Check } from './types';
12
+
13
+ /** Removes the tailnet device a replaced or retired worker ran as, so the
14
+ * next machine under its id gets the declared name. Asked through the
15
+ * control plane fleet.json declares (`tailnet`), with the `tailnet-admin`
16
+ * credential; the credential is read when a device is removed, never kept.
17
+ */
18
+ export interface TailnetCleanup {
19
+ /** Removes the device `deviceId` that `worker` ran as; a check
20
+ * `tailscale:device` that says whether it is gone.
21
+ */
22
+ removeDevice(worker: MachineDefinition, deviceId: string): Promise<Check>;
23
+ }
24
+
25
+ /** The device left to a person: no control plane is declared, or no
26
+ * credential to ask it with is stored.
27
+ */
28
+ export function deviceLeftToPerson(
29
+ worker: MachineDefinition,
30
+ device: string,
31
+ why = 'no tailnet control is configured'
32
+ ): Check {
33
+ return {
34
+ id: 'tailscale:device',
35
+ status: 'skip',
36
+ summary: `The tailnet device ${device} was not removed: ${why}.`,
37
+ remedy: {
38
+ action: `Remove the device ${device} in the tailnet's admin console, so that a new machine gets the name ${worker.network?.tailscaleName ?? worker.id}. To have it removed by itself, declare tailnet in fleet.json and store the secret tailnet-admin.`,
39
+ },
40
+ };
41
+ }
42
+
43
+ /** The cleanup through the tailnet control plane a fleet declares. */
44
+ export function tailnetCleanup(
45
+ config: TailnetConfig,
46
+ secret: SecretsProvider,
47
+ http?: ControlHttp
48
+ ): TailnetCleanup {
49
+ return {
50
+ async removeDevice(worker, device) {
51
+ let control: TailnetControl | null;
52
+ try {
53
+ control = await fleetTailnetControl(config, secret, http);
54
+ } catch (error) {
55
+ if (
56
+ error instanceof TailnetControlError &&
57
+ error.code === 'TAILNET_ADMIN_REQUIRED'
58
+ )
59
+ return deviceLeftToPerson(
60
+ worker,
61
+ device,
62
+ 'the secrets provider holds no tailnet-admin'
63
+ );
64
+ throw error;
65
+ }
66
+ if (!control) return deviceLeftToPerson(worker, device);
67
+ try {
68
+ await control.deleteDevice(device);
69
+ } catch (error) {
70
+ if (!(error instanceof TailnetControlError)) throw error;
71
+ // A device that is already gone is what was asked for.
72
+ if (!(await stillListed(control, config.tag, device)))
73
+ return removed(device);
74
+ return {
75
+ id: 'tailscale:device',
76
+ status: 'warn',
77
+ summary: error.code,
78
+ remedy: {
79
+ action: `${controlRemedy(error.code)} Or remove the device ${device} in the tailnet's admin console.`,
80
+ },
81
+ };
82
+ }
83
+ return removed(device);
84
+ },
85
+ };
86
+ }
87
+
88
+ const removed = (device: string): Check => ({
89
+ id: 'tailscale:device',
90
+ status: 'pass',
91
+ summary: `The tailnet no longer holds the device ${device}.`,
92
+ });
93
+
94
+ async function stillListed(
95
+ control: TailnetControl,
96
+ tag: string,
97
+ device: string
98
+ ): Promise<boolean> {
99
+ try {
100
+ return (await control.listDevices(tag)).some(
101
+ (entry) => entry.id === device
102
+ );
103
+ } catch {
104
+ return true;
105
+ }
106
+ }
107
+
108
+ /** A device name as the control plane gives it, for a report: one DNS-like
109
+ * name, or nothing. Never anything else the control plane answered.
110
+ */
111
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
112
+ const label = (name: string): string =>
113
+ name.toLowerCase().replace(/\.$/, '').split('.')[0]!;
114
+
115
+ /** Devices that carry the fleet's worker tag and are no declared worker
116
+ * (parked ones included) nor a device the roster binds: a machine left over
117
+ * from a rebuild, or one joined by hand. Each is a warning `tailscale:leftover`
118
+ * that names the device by its name only. A fleet without a control plane,
119
+ * or without the credential to ask it, says nothing here.
120
+ */
121
+ export async function tailnetLeftovers(
122
+ definition: FleetDefinition,
123
+ control: () => Promise<TailnetControl | null>,
124
+ bound: string[] = []
125
+ ): Promise<Check[]> {
126
+ const tag = definition.tailnet?.tag;
127
+ if (!tag) return [];
128
+ let devices;
129
+ try {
130
+ const found = await control();
131
+ if (!found) return [];
132
+ devices = await found.listDevices(tag);
133
+ } catch (error) {
134
+ if (!(error instanceof TailnetControlError)) throw error;
135
+ if (error.code === 'TAILNET_ADMIN_REQUIRED') return [];
136
+ return [
137
+ {
138
+ id: 'tailscale:leftover',
139
+ status: 'skip',
140
+ summary: error.code,
141
+ remedy: { action: controlRemedy(error.code) },
142
+ },
143
+ ];
144
+ }
145
+ const declared = new Set(
146
+ [definition.manager, ...definition.workers].flatMap((machine) => [
147
+ machine.id,
148
+ ...(machine.network ? [label(machine.network.tailscaleName)] : []),
149
+ ])
150
+ );
151
+ return devices
152
+ .filter(
153
+ (device) =>
154
+ !bound.includes(device.id) && !declared.has(label(device.name))
155
+ )
156
+ .map((device) => {
157
+ const name = NAME.test(device.name) ? device.name : 'without a name';
158
+ return {
159
+ id: 'tailscale:leftover',
160
+ status: 'warn' as const,
161
+ summary: `The tailnet holds the device ${name}, tagged ${tag}, which is no declared worker.`,
162
+ remedy: {
163
+ action: `If the device ${name} left the fleet, remove it in the tailnet's admin console. If it is a worker, declare it in fleet.json (or its worker record).`,
164
+ },
165
+ };
166
+ });
167
+ }
168
+
169
+ /** Adds the leftover devices to a fleet-status report, as their own
170
+ * `tailnet` entry; nothing when there are none.
171
+ */
172
+ export async function appendTailnetLeftovers(
173
+ reports: { node: string; checks: Check[] }[],
174
+ definition: FleetDefinition,
175
+ control: (() => Promise<TailnetControl | null>) | undefined,
176
+ nodes: { tailscaleId?: string }[]
177
+ ): Promise<void> {
178
+ if (!control) return;
179
+ const checks = await tailnetLeftovers(
180
+ definition,
181
+ control,
182
+ nodes.flatMap((node) => (node.tailscaleId ? [node.tailscaleId] : []))
183
+ );
184
+ if (checks.length) reports.push({ node: 'tailnet', checks });
185
+ }