@crvouga/dev-fleet 0.8.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 (204) hide show
  1. package/README.md +65 -9
  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 +55 -9
  14. package/ansible/macos/manager.yml +154 -7
  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/monitoring/agent-darwin.yml +3 -0
  19. package/ansible/monitoring/agent.yml +2 -0
  20. package/ansible/monitoring/hub-darwin.yml +3 -0
  21. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  22. package/ansible/net/remote-probe.sh +4 -1
  23. package/ansible/platforms.json +2 -2
  24. package/ansible/pyproject.toml +5 -0
  25. package/ansible/ubuntu/baseline.yml +39 -13
  26. package/ansible/ubuntu/disk-gc.py +6 -2
  27. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  28. package/ansible/ubuntu/policy.json +125 -6
  29. package/ansible/ubuntu/reboot.yml +89 -0
  30. package/ansible/ubuntu/recovery.yml +13 -3
  31. package/ansible/ubuntu/t3/policy.json +3 -0
  32. package/ansible/ubuntu/t3-desktop.yml +23 -2
  33. package/ansible/ubuntu/t3-headless.yml +128 -0
  34. package/ansible/ubuntu/t3-health.sh +24 -10
  35. package/ansible/ubuntu/t3-idle.py +37 -2
  36. package/ansible/ubuntu/t3-policy.yml +4 -1
  37. package/ansible/ubuntu/t3-route.yml +1 -0
  38. package/ansible/ubuntu/tailscale.yml +54 -8
  39. package/ansible/ubuntu/tasks/browser.yml +3 -0
  40. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  41. package/ansible/ubuntu/tasks/docker.yml +53 -6
  42. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  43. package/ansible/ubuntu/tasks/features.yml +22 -0
  44. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  45. package/ansible/ubuntu/tasks/install.yml +2 -1
  46. package/ansible/ubuntu/tasks/packages.yml +4 -2
  47. package/ansible/ubuntu/tasks/release.yml +6 -0
  48. package/ansible/ubuntu/tasks/resources.yml +6 -4
  49. package/ansible/ubuntu/tasks/services.yml +2 -2
  50. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  51. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  52. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  53. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  54. package/ansible/ubuntu/tasks/user.yml +1 -0
  55. package/ansible/ubuntu/worker-account.yml +117 -0
  56. package/ansible/uv.lock +265 -0
  57. package/bin/devfleet.mjs +42 -4
  58. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  59. package/bootstrap/global-config.ts +29 -3
  60. package/bootstrap/package-smoke.ts +127 -0
  61. package/bootstrap/pins.ts +189 -0
  62. package/bootstrap/release-notes.ts +88 -0
  63. package/bootstrap/release-version.ts +41 -7
  64. package/bootstrap/worker-features.ts +114 -0
  65. package/bootstrap/worker-seed.yaml +7 -3
  66. package/bootstrap/worker-tools.ts +39 -9
  67. package/cliproxy/README.md +1 -1
  68. package/cliproxy/docs/health-workflow.example.yml +20 -143
  69. package/docs/authentication.md +19 -11
  70. package/docs/consumer-setup.md +42 -27
  71. package/docs/convergence.md +67 -0
  72. package/docs/declared.md +61 -34
  73. package/docs/machine-setup.md +73 -7
  74. package/docs/manager-setup.md +10 -3
  75. package/docs/operations.md +64 -9
  76. package/docs/releasing.md +49 -1
  77. package/docs/runbooks.md +98 -7
  78. package/docs/state.md +23 -11
  79. package/docs/support-matrix.md +40 -4
  80. package/docs/topology.md +67 -6
  81. package/docs/ubuntu-sync.md +78 -28
  82. package/docs/worker-contract.md +122 -19
  83. package/docs/worker-requirements.md +107 -0
  84. package/examples/fleet.json +1 -0
  85. package/examples/workers-from-infrastructure/main.tf +132 -0
  86. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  87. package/llms.txt +3 -1
  88. package/mise.lock +29 -0
  89. package/mise.toml +5 -3
  90. package/package.json +2 -2
  91. package/schema/check-ids.json +1967 -170
  92. package/schema/check-ids.schema.json +34 -0
  93. package/schema/fleet.schema.json +327 -8
  94. package/schema/output/check.schema.json +1 -1
  95. package/schema/output/fleet-state-definition.schema.json +14 -0
  96. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  97. package/schema/output/fleet-sync.schema.json +18 -0
  98. package/schema/output/report.schema.json +3 -2
  99. package/schema/worker-record.schema.json +76 -0
  100. package/src/agents/converge.ts +140 -24
  101. package/src/agents/files.ts +2 -32
  102. package/src/agents/native-verify.ts +34 -3
  103. package/src/agents/secrets.ts +39 -4
  104. package/src/check-owners.ts +61 -2
  105. package/src/check-registry.ts +630 -0
  106. package/src/cli/agent-guide.ts +3 -3
  107. package/src/cli/cli.ts +4 -0
  108. package/src/cli/fleet-manifest.ts +9 -3
  109. package/src/cli/fleet-node-lifecycle.ts +313 -64
  110. package/src/cli/fleet-node-park.ts +238 -0
  111. package/src/cli/fleet-node-seed.ts +51 -35
  112. package/src/cli/fleet-state.ts +2 -1
  113. package/src/cli/fleet-sync-checkout.ts +7 -0
  114. package/src/cli/fleet-sync-enrollment.ts +193 -32
  115. package/src/cli/fleet-sync-forward.ts +4 -0
  116. package/src/cli/fleet-sync-manager.ts +103 -33
  117. package/src/cli/fleet-sync-output.ts +31 -11
  118. package/src/cli/fleet-sync-plan.ts +65 -9
  119. package/src/cli/fleet-sync-records.ts +113 -0
  120. package/src/cli/fleet-sync-remote.ts +151 -9
  121. package/src/cli/fleet-sync-schedule.ts +14 -4
  122. package/src/cli/fleet-sync-state.ts +28 -16
  123. package/src/cli/fleet-sync-vault.ts +77 -0
  124. package/src/cli/fleet-sync-worker.ts +214 -0
  125. package/src/cli/fleet-sync.ts +150 -19
  126. package/src/cli/fleet-validate.ts +59 -21
  127. package/src/cli/fleet-watch.ts +7 -1
  128. package/src/cli/fleet.ts +87 -5
  129. package/src/cli/managed-health.ts +25 -1
  130. package/src/cli/register-fleet.ts +115 -4
  131. package/src/cli/registry.ts +14 -0
  132. package/src/cli/tailnet.ts +43 -10
  133. package/src/cli/ui-data.ts +3 -1
  134. package/src/cli/worker-usage-report.ts +63 -0
  135. package/src/clock.ts +42 -0
  136. package/src/definition.ts +332 -9
  137. package/src/diagnostics.ts +5 -2
  138. package/src/exposure.ts +102 -0
  139. package/src/fleet-config.ts +3 -1
  140. package/src/git-identity.ts +76 -3
  141. package/src/ide/ide.ts +5 -0
  142. package/src/ide/t3.ts +12 -2
  143. package/src/input-hashes.ts +6 -0
  144. package/src/machine/machine-class.ts +59 -0
  145. package/src/machine/platform.ts +90 -11
  146. package/src/machine/state.ts +37 -15
  147. package/src/machine/support-matrix.ts +11 -9
  148. package/src/machine-health.ts +28 -5
  149. package/src/manager-health.ts +148 -2
  150. package/src/manager-sync.ts +65 -4
  151. package/src/monitoring/beszel.ts +8 -8
  152. package/src/monitoring/enroll.ts +8 -1
  153. package/src/monitoring/hub-access.ts +10 -4
  154. package/src/monitoring/systems.ts +138 -0
  155. package/src/network-definition.ts +13 -4
  156. package/src/node-converge-linux.ts +136 -27
  157. package/src/node-converge.ts +13 -3
  158. package/src/platform/ansible-receipt.ts +17 -2
  159. package/src/platform/ansible.ts +40 -4
  160. package/src/platform/manager-host.ts +25 -8
  161. package/src/platform/resource-verify.ts +18 -7
  162. package/src/platform/t3-policy.ts +24 -10
  163. package/src/platform/t3-service.ts +109 -34
  164. package/src/platform/ubuntu-release.ts +24 -0
  165. package/src/platform/ubuntu-verify.ts +31 -3
  166. package/src/platform/ubuntu-worker.ts +58 -0
  167. package/src/platform/worker-platform.ts +72 -0
  168. package/src/protocol.ts +1 -1
  169. package/src/public-reachability.ts +186 -0
  170. package/src/remote-bootstrap.ts +50 -12
  171. package/src/remote-operation.ts +18 -4
  172. package/src/repair-plan.ts +23 -12
  173. package/src/report.ts +2 -1
  174. package/src/schedule-check.ts +21 -2
  175. package/src/secrets.ts +4 -0
  176. package/src/service-auth.ts +55 -48
  177. package/src/ssh-connection.ts +141 -0
  178. package/src/ssh.ts +63 -24
  179. package/src/state/store.ts +4 -2
  180. package/src/state/sync.ts +35 -6
  181. package/src/state/workers.ts +140 -0
  182. package/src/t3/pairing.ts +59 -0
  183. package/src/t3/peer-link.ts +333 -0
  184. package/src/t3/version.ts +1 -1
  185. package/src/tailnet-cleanup.ts +185 -0
  186. package/src/tailnet-control.ts +346 -0
  187. package/src/tailscale.ts +1 -1
  188. package/src/toolchain-checks.ts +11 -1
  189. package/src/types.ts +4 -2
  190. package/src/worker-account.ts +63 -0
  191. package/src/worker-activity.ts +303 -0
  192. package/src/worker-capacity.ts +31 -0
  193. package/src/worker-exposure.ts +388 -0
  194. package/src/worker-features.ts +201 -0
  195. package/src/worker-host-checks.ts +71 -20
  196. package/src/worker-manifest.ts +27 -4
  197. package/src/worker-parking.ts +70 -0
  198. package/src/worker-policy.ts +17 -1
  199. package/src/worker-readiness.ts +15 -3
  200. package/src/worker-reboot.ts +129 -0
  201. package/src/worker-records.ts +305 -0
  202. package/src/worker-seed.ts +16 -11
  203. package/src/worker-sizing.ts +76 -0
  204. package/src/cli/schedule-logs.ts +0 -36
@@ -17,9 +17,11 @@ import { FleetUsageError } from '../errors';
17
17
  */
18
18
  export const DOCUMENTS = ['definition', 'roster', 'enrollment'] as const;
19
19
  /** Kept when there is something to keep; a fleet without them is complete:
20
- * `history` is the record of recent fleet-sync runs and of the schedule.
20
+ * `history` is the record of recent fleet-sync runs and of the schedule;
21
+ * `workers` what the manager knows of each worker beyond fleet.json: its
22
+ * pinned SSH host key and whether it is parked (src/state/workers.ts).
21
23
  */
22
- export const OPTIONAL_DOCUMENTS = ['history'] as const;
24
+ export const OPTIONAL_DOCUMENTS = ['history', 'workers'] as const;
23
25
  export type DocumentName = (typeof DOCUMENTS)[number];
24
26
  /** Every document the fleet state may hold. */
25
27
  export type StoredName = DocumentName | (typeof OPTIONAL_DOCUMENTS)[number];
package/src/state/sync.ts CHANGED
@@ -349,9 +349,14 @@ export function pull(context: StateContext): Promise<Standing> {
349
349
  }
350
350
 
351
351
  /** Replaces this copy with the version the bucket held at `timestamp`, then
352
- * uploads it as the bucket's current version: the way back from a replica
353
- * whose latest version is damaged. `unreachable` when the bucket holds no
354
- * readable version at that time.
352
+ * uploads it as the bucket's current version, which takes the manager lease:
353
+ * the way back from a replica whose latest version is damaged. `unreachable`
354
+ * when the bucket holds no readable version at that time.
355
+ *
356
+ * Refused, before anything changes, while another manager holds the lease in
357
+ * the bucket's current version, unless told to take it over. When that
358
+ * version cannot be read the lease of this machine's copy, the last one it
359
+ * saw, decides instead.
355
360
  */
356
361
  export function pullAt(
357
362
  context: StateContext,
@@ -367,6 +372,17 @@ export function pullAt(
367
372
  'No bucket is configured, so there is no earlier version to restore.'
368
373
  );
369
374
  return locked(context.root, async () => {
375
+ const database = databasePath(context.root);
376
+ const current = await fetchRemote({ ...context, bucket });
377
+ try {
378
+ const held = leaseIn(
379
+ context,
380
+ current.status === 'present' ? current.file : database
381
+ );
382
+ if (held) throw new LeaseHeldError(leaseHeldMessage(held));
383
+ } finally {
384
+ if (current.status === 'present') discard(current.file);
385
+ }
370
386
  const remote = await fetchRemote(
371
387
  { ...context, bucket },
372
388
  true,
@@ -374,10 +390,17 @@ export function pullAt(
374
390
  );
375
391
  if (remote.status !== 'present')
376
392
  return remote.status === 'absent' ? 'empty' : remote.status;
377
- const database = databasePath(context.root);
378
393
  discard(database);
379
394
  renameSync(remote.file, database);
380
395
  chmodSync(database, 0o600);
396
+ // The earlier version names whoever held the lease then.
397
+ if (context.holder) {
398
+ const now = (context.now ?? (() => new Date()))();
399
+ writeLease(database, {
400
+ holder: context.holder,
401
+ expiresAt: new Date(now.getTime() + LEASE_MS).toISOString(),
402
+ });
403
+ }
381
404
  return upload({ ...context, bucket }, true);
382
405
  });
383
406
  }
@@ -408,11 +431,17 @@ export class LeaseHeldError extends FleetUsageError {}
408
431
  * and this machine was not told to take it over. Reads local files only: call
409
432
  * it on the bucket's version, after a refresh.
410
433
  */
411
- export function leaseConflict(
434
+ export function leaseConflict(context: StateContext, now?: Date): Lease | null {
435
+ return leaseIn(context, databasePath(context.root), now);
436
+ }
437
+
438
+ /** The lease another manager holds over the database at `file`. */
439
+ function leaseIn(
412
440
  context: StateContext,
441
+ file: string,
413
442
  now: Date = (context.now ?? (() => new Date()))()
414
443
  ): Lease | null {
415
- const lease = readLease(databasePath(context.root));
444
+ const lease = readLease(file);
416
445
  if (!context.holder || !lease || lease.holder === context.holder) return null;
417
446
  if (Date.parse(lease.expiresAt) <= now.getTime() || context.takeOver)
418
447
  return null;
@@ -0,0 +1,140 @@
1
+ import { record } from '../config';
2
+ import { HOST_KEY } from '../definition';
3
+ import { databasePath, stateRoot } from './paths';
4
+ import { readDocument, writeDocument } from './store';
5
+ import { mutateState, type StateContext, type Standing } from './sync';
6
+
7
+ /** What the manager knows of one worker that fleet.json does not say. */
8
+ export interface WorkerState {
9
+ /** The pinned SSH host key, "TYPE BASE64", and when it was pinned. */
10
+ hostKey?: string;
11
+ hostKeyPinnedAt?: string;
12
+ /** When the worker was parked: stopped or destroyed on purpose. Absent,
13
+ * the worker is active.
14
+ */
15
+ parkedAt?: string;
16
+ /** `records`: the worker was enrolled from a worker record, so it is
17
+ * retired by itself once its record disappears.
18
+ */
19
+ origin?: 'records';
20
+ /** When fleet-node-replace put a new machine in the worker's place: the
21
+ * manager pairs its T3 with the new machine's T3 environment as soon as
22
+ * it runs, then forgets this. A worker enrolled for the first time is
23
+ * paired by a person.
24
+ */
25
+ t3PairingRequestedAt?: string;
26
+ }
27
+
28
+ /** The `workers` document of the fleet state. */
29
+ export interface WorkerStates {
30
+ version: 1;
31
+ workers: Record<string, WorkerState>;
32
+ }
33
+
34
+ const ID = /^[a-z][a-z0-9-]{0,62}$/;
35
+ const isTime = (value: unknown): value is string =>
36
+ typeof value === 'string' && !Number.isNaN(Date.parse(value));
37
+
38
+ const RULES: Record<keyof WorkerState, (value: unknown) => boolean> = {
39
+ hostKey: (value) => typeof value === 'string' && HOST_KEY.test(value),
40
+ hostKeyPinnedAt: isTime,
41
+ parkedAt: isTime,
42
+ origin: (value) => value === 'records',
43
+ t3PairingRequestedAt: isTime,
44
+ };
45
+
46
+ function workerState(value: unknown): WorkerState | null {
47
+ if (!record(value)) return null;
48
+ const broken = Object.entries(RULES).some(
49
+ ([field, valid]) => value[field] !== undefined && !valid(value[field])
50
+ );
51
+ if (broken) return null;
52
+ const { hostKey, hostKeyPinnedAt, parkedAt, origin, t3PairingRequestedAt } =
53
+ value;
54
+ return {
55
+ ...(typeof hostKey === 'string' ? { hostKey } : {}),
56
+ ...(isTime(hostKeyPinnedAt) ? { hostKeyPinnedAt } : {}),
57
+ ...(isTime(parkedAt) ? { parkedAt } : {}),
58
+ ...(origin === 'records' ? { origin } : {}),
59
+ ...(isTime(t3PairingRequestedAt) ? { t3PairingRequestedAt } : {}),
60
+ };
61
+ }
62
+
63
+ /** The document, or an empty one. An entry that cannot be read is dropped:
64
+ * an unreadable pin pins nothing, so the worker is refused, never trusted.
65
+ */
66
+ export function parseWorkerStates(text: string | null): WorkerStates {
67
+ let value: unknown = null;
68
+ try {
69
+ value = text === null ? null : JSON.parse(text);
70
+ } catch {
71
+ value = null;
72
+ }
73
+ const workers: Record<string, WorkerState> = {};
74
+ if (record(value) && record(value['workers']))
75
+ for (const [id, entry] of Object.entries(value['workers'])) {
76
+ const parsed = workerState(entry);
77
+ if (ID.test(id) && parsed) workers[id] = parsed;
78
+ }
79
+ return { version: 1, workers };
80
+ }
81
+
82
+ export function readWorkerStates(env = process.env): WorkerStates {
83
+ return parseWorkerStates(
84
+ readDocument(databasePath(stateRoot(env)), 'workers')
85
+ );
86
+ }
87
+
88
+ /** The host keys pinned in the fleet state, by worker ID. */
89
+ export function readPins(env = process.env): Record<string, string> {
90
+ return Object.fromEntries(
91
+ Object.entries(readWorkerStates(env).workers).flatMap(([id, entry]) =>
92
+ entry.hostKey ? [[id, entry.hostKey]] : []
93
+ )
94
+ );
95
+ }
96
+
97
+ /** When each parked worker was parked, by worker ID. */
98
+ export function readParked(env = process.env): Record<string, string> {
99
+ return Object.fromEntries(
100
+ Object.entries(readWorkerStates(env).workers).flatMap(([id, entry]) =>
101
+ entry.parkedAt ? [[id, entry.parkedAt]] : []
102
+ )
103
+ );
104
+ }
105
+
106
+ function writeWorkerStates(states: WorkerStates, env = process.env): void {
107
+ const workers = Object.fromEntries(
108
+ Object.entries(states.workers)
109
+ .filter(([, entry]) => Object.keys(entry).length > 0)
110
+ .sort(([a], [b]) => a.localeCompare(b))
111
+ );
112
+ writeDocument(
113
+ databasePath(stateRoot(env)),
114
+ 'workers',
115
+ `${JSON.stringify({ version: 1, workers }, null, 2)}\n`
116
+ );
117
+ }
118
+
119
+ /** Changes the entries of some workers on top of the bucket's version and
120
+ * uploads the result. `change` returns the new entry of each worker it
121
+ * changes, or null to forget the worker.
122
+ */
123
+ export async function changeWorkers(
124
+ context: StateContext,
125
+ change: (
126
+ current: Record<string, WorkerState>
127
+ ) => Record<string, WorkerState | null>
128
+ ): Promise<{ workers: Record<string, WorkerState>; standing: Standing }> {
129
+ const { value, standing } = await mutateState(context, () => {
130
+ const states = readWorkerStates();
131
+ const updates = change(states.workers);
132
+ const workers = { ...states.workers };
133
+ for (const [id, entry] of Object.entries(updates))
134
+ if (entry === null) delete workers[id];
135
+ else workers[id] = entry;
136
+ writeWorkerStates({ version: 1, workers });
137
+ return workers;
138
+ });
139
+ return { workers: value, standing };
140
+ }
package/src/t3/pairing.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { parseConfig } from '../config';
2
2
  import { STATE_ROSTER, readRosterText, writeRosterText } from '../state/roster';
3
3
  import { mutateState, stateContext, type StateContext } from '../state/sync';
4
+ import { changeWorkers, readWorkerStates } from '../state/workers';
5
+ import type { Check, FleetNode, Runner } from '../types';
6
+ import { pairWorker, pairingCheck, type ManagerT3 } from './peer-link';
4
7
 
5
8
  /** Records the T3 environment a worker runs in the roster, the first time a
6
9
  * verified link from the manager reaches it. From then on the link check
@@ -29,3 +32,59 @@ export async function rememberEnvironment(
29
32
  if (path !== STATE_ROSTER) return save();
30
33
  return (await mutateState(context(), save)).value;
31
34
  }
35
+
36
+ /** What pairing a worker needs: where the roster is, how commands run, the
37
+ * manager's T3 and the fleet state. */
38
+ export interface PairingServices {
39
+ roster: string;
40
+ run: Runner;
41
+ manager: ManagerT3;
42
+ context: () => StateContext;
43
+ now: () => Date;
44
+ }
45
+
46
+ /** Pairs the manager's T3 with the environment a worker runs now, without a
47
+ * person (src/t3/peer-link.ts), and records it in the roster. Until it is
48
+ * paired, the fleet state keeps the request, so the next fleet-sync pairs
49
+ * it once the worker's T3 runs; once paired, the request is forgotten.
50
+ */
51
+ export async function pairAndRecord(
52
+ node: FleetNode,
53
+ services: PairingServices
54
+ ): Promise<Check> {
55
+ const pairing = await pairWorker(node, services.run, services.manager);
56
+ if ('paired' in pairing)
57
+ await rememberEnvironment(
58
+ services.roster,
59
+ node.id,
60
+ pairing.paired,
61
+ services.context
62
+ );
63
+ const requested = services.now().toISOString();
64
+ await changeWorkers(services.context(), (current) => {
65
+ const { t3PairingRequestedAt: earlier, ...rest } = current[node.id] ?? {};
66
+ return {
67
+ [node.id]:
68
+ 'paired' in pairing
69
+ ? rest
70
+ : { ...rest, t3PairingRequestedAt: earlier ?? requested },
71
+ };
72
+ });
73
+ return pairingCheck(node.id, pairing);
74
+ }
75
+
76
+ /** fleet-sync's part: pairs a worker only when fleet-node-replace asked for
77
+ * it. A worker enrolled for the first time is paired by a person.
78
+ */
79
+ export async function pairIfRequested(
80
+ node: FleetNode,
81
+ services: Omit<PairingServices, 'context' | 'now'> &
82
+ Partial<Pick<PairingServices, 'context'>>
83
+ ): Promise<Check | undefined> {
84
+ if (!readWorkerStates().workers[node.id]?.t3PairingRequestedAt) return;
85
+ return pairAndRecord(node, {
86
+ context: () => stateContext(services.run),
87
+ ...services,
88
+ now: () => new Date(),
89
+ });
90
+ }
@@ -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