@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,141 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { dirname, join } from 'node:path';
10
+ import type { FleetDefinition, MachineDefinition } from './definition';
11
+ import { stateRoot } from './state/paths';
12
+
13
+ /** How the manager reaches a worker, written once: every direct SSH session
14
+ * (src/ssh.ts) and every Ansible run over SSH (fleet-validate's inventory,
15
+ * with ansible/ansible.cfg) takes its options from here, so the two cannot
16
+ * drift. Host keys are checked strictly and never learned on first use. A
17
+ * few attempts and keepalives let a short network blip pass without ending
18
+ * a long operation.
19
+ */
20
+ export const SSH_TRANSPORT: readonly (readonly [string, string])[] = [
21
+ ['BatchMode', 'yes'],
22
+ ['StrictHostKeyChecking', 'yes'],
23
+ ['UpdateHostKeys', 'no'],
24
+ ['ConnectTimeout', '10'],
25
+ ['ConnectionAttempts', '3'],
26
+ ['ServerAliveInterval', '15'],
27
+ ['ServerAliveCountMax', '4'],
28
+ ];
29
+
30
+ /** A direct session also forwards nothing and shares no connection. Ansible
31
+ * multiplexes its own (ControlPersist in ansible/ansible.cfg).
32
+ */
33
+ export const SSH_SESSION: readonly (readonly [string, string])[] = [
34
+ ['ClearAllForwardings', 'yes'],
35
+ ['PermitLocalCommand', 'no'],
36
+ ['ControlMaster', 'no'],
37
+ ['ControlPath', 'none'],
38
+ ];
39
+
40
+ /** The name a worker's host key is pinned under, whatever its address. */
41
+ export const hostKeyAlias = (id: string): string => `devfleet-${id}`;
42
+
43
+ /** The fleet-owned known_hosts file: a cache rendered from the pinned keys
44
+ * (declared in a worker record, or in the fleet state). The operator's own
45
+ * ~/.ssh files are neither read for it nor changed.
46
+ */
47
+ export function knownHostsPath(env = process.env, home = homedir()): string {
48
+ return join(dirname(stateRoot(env, home)), 'ssh', 'known_hosts');
49
+ }
50
+
51
+ /** A worker without an SSH alias is reached from its declared network
52
+ * block: its tailnet name, user and port.
53
+ */
54
+ export function declaredConnection(
55
+ worker: MachineDefinition | undefined
56
+ ):
57
+ | (MachineDefinition & { network: NonNullable<MachineDefinition['network']> })
58
+ | null {
59
+ return worker && worker.ssh === undefined && worker.network
60
+ ? (worker as MachineDefinition & {
61
+ network: NonNullable<MachineDefinition['network']>;
62
+ })
63
+ : null;
64
+ }
65
+
66
+ /** Host keys pinned in the fleet state, by worker ID. Only the devfleet CLI
67
+ * reads the state (it registers the reader); launchers and the worker probe
68
+ * run under Node, which cannot open the state database.
69
+ */
70
+ type PinReader = () => Record<string, string>;
71
+ let pinReader: PinReader | null = null;
72
+ export function readPinsWith(reader: PinReader | null): void {
73
+ pinReader = reader;
74
+ }
75
+
76
+ /** The key each worker reached from declared data is pinned to: the one its
77
+ * record or fleet.json declares, else the one the fleet state pins.
78
+ */
79
+ export function pinnedKeys(
80
+ definition: FleetDefinition,
81
+ pins: Record<string, string> = pinReader?.() ?? {}
82
+ ): Record<string, string> {
83
+ return Object.fromEntries(
84
+ definition.workers.flatMap((worker) => {
85
+ const declared = declaredConnection(worker);
86
+ const key = declared?.network.hostKey ?? pins[worker.id];
87
+ return declared && key ? [[worker.id, key]] : [];
88
+ })
89
+ );
90
+ }
91
+
92
+ export function knownHostsText(keys: Record<string, string>): string {
93
+ return [
94
+ '# Managed by dev-fleet: rendered from the fleet state and worker records. Do not edit.',
95
+ ...Object.entries(keys)
96
+ .sort(([a], [b]) => a.localeCompare(b))
97
+ .map(([id, key]) => `${hostKeyAlias(id)} ${key}`),
98
+ '',
99
+ ].join('\n');
100
+ }
101
+
102
+ /** Writes the fleet-owned known_hosts when it differs; returns its path. */
103
+ export function renderKnownHosts(
104
+ definition: FleetDefinition,
105
+ env = process.env,
106
+ pins?: Record<string, string>
107
+ ): string {
108
+ const path = knownHostsPath(env);
109
+ const text = knownHostsText(pinnedKeys(definition, pins));
110
+ if (existsSync(path) && readFileSync(path, 'utf8') === text) return path;
111
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
112
+ writeFileSync(`${path}.new`, text, { mode: 0o600 });
113
+ renameSync(`${path}.new`, path);
114
+ return path;
115
+ }
116
+
117
+ const option = ([name, value]: readonly [string, string]): string =>
118
+ `${name}=${value}`;
119
+
120
+ /** The options that reach one worker from declared data. */
121
+ export function connectionOptions(
122
+ worker: MachineDefinition & {
123
+ network: NonNullable<MachineDefinition['network']>;
124
+ },
125
+ knownHosts: string
126
+ ): [string, string][] {
127
+ return [
128
+ ['HostKeyAlias', hostKeyAlias(worker.id)],
129
+ ['UserKnownHostsFile', knownHosts],
130
+ ['GlobalKnownHostsFile', '/dev/null'],
131
+ ['User', worker.network.user],
132
+ ['Port', String(worker.network.sshPort)],
133
+ ];
134
+ }
135
+
136
+ /** `-o NAME=VALUE` for each option, as ssh takes them. */
137
+ export function sshOptionArgs(
138
+ options: readonly (readonly [string, string])[]
139
+ ): string[] {
140
+ return options.flatMap((entry) => ['-o', option(entry)]);
141
+ }
package/src/ssh.ts CHANGED
@@ -1,40 +1,79 @@
1
1
  import type { CommandResult, FleetNode, Check } from './types';
2
+ import { fleet } from './fleet-config';
3
+ import {
4
+ connectionOptions,
5
+ declaredConnection,
6
+ renderKnownHosts,
7
+ SSH_SESSION,
8
+ SSH_TRANSPORT,
9
+ sshOptionArgs,
10
+ } from './ssh-connection';
2
11
 
3
12
  export function shellQuote(value: string): string {
4
13
  return `'${value.replaceAll("'", "'\\''")}'`;
5
14
  }
6
15
 
7
- export function sshArgs(node: FleetNode): string[] {
16
+ /** The worker fleet.json declares for a roster entry that is reached from
17
+ * declared data, if it is one. No configuration (a fresh machine) is none.
18
+ */
19
+ function declaredWorker(node: Pick<FleetNode, 'id' | 'ssh'>) {
20
+ try {
21
+ const worker = declaredConnection(
22
+ fleet.workers.find((entry) => entry.id === node.id)
23
+ );
24
+ return worker && worker.network.tailscaleName === node.ssh ? worker : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** One SSH session to a node: unattended, strict about the host key,
31
+ * forwarding nothing. A worker without an alias is reached from its declared
32
+ * user, port and tailnet name, against the fleet-owned known_hosts.
33
+ */
34
+ export function sshArgs(node: Pick<FleetNode, 'id' | 'ssh'>): string[] {
35
+ const declared = declaredWorker(node);
8
36
  return [
9
37
  'ssh',
10
38
  '-T',
11
39
  '-a',
12
- '-o',
13
- 'BatchMode=yes',
14
- '-o',
15
- 'StrictHostKeyChecking=yes',
16
- '-o',
17
- 'UpdateHostKeys=no',
18
- '-o',
19
- 'ConnectTimeout=8',
20
- '-o',
21
- 'ConnectionAttempts=1',
22
- '-o',
23
- 'ServerAliveInterval=15',
24
- '-o',
25
- 'ServerAliveCountMax=4',
26
- '-o',
27
- 'ClearAllForwardings=yes',
28
- '-o',
29
- 'PermitLocalCommand=no',
30
- '-o',
31
- 'ControlMaster=no',
32
- '-o',
33
- 'ControlPath=none',
40
+ ...sshOptionArgs(SSH_TRANSPORT),
41
+ ...(declared
42
+ ? sshOptionArgs(connectionOptions(declared, renderKnownHosts(fleet)))
43
+ : []),
44
+ ...sshOptionArgs(SSH_SESSION),
34
45
  node.ssh,
35
46
  ];
36
47
  }
37
48
 
49
+ /** How a package playbook that addresses a worker over SSH itself
50
+ * (ansible/ubuntu/worker-account.yml, ansible/ubuntu/reboot.yml) reaches it:
51
+ * the options every direct session takes, as one `ansible_ssh_common_args`
52
+ * value, and the account a worker reached from declared data logs in as. The
53
+ * play names the login itself (root, or the declared account), so the
54
+ * declared user is not among the options.
55
+ */
56
+ export function playbookConnection(node: Pick<FleetNode, 'id' | 'ssh'>): {
57
+ options: string;
58
+ user: string | null;
59
+ } {
60
+ const declared = declaredWorker(node);
61
+ const options = [
62
+ ...SSH_TRANSPORT,
63
+ ...(declared
64
+ ? connectionOptions(declared, renderKnownHosts(fleet)).filter(
65
+ ([name]) => name !== 'User'
66
+ )
67
+ : []),
68
+ ];
69
+ return {
70
+ options: sshOptionArgs(options)
71
+ .map((arg) => (arg === '-o' ? arg : shellQuote(arg)))
72
+ .join(' '),
73
+ user: declared?.network.user ?? null,
74
+ };
75
+ }
76
+
38
77
  export function remoteCommand(node: FleetNode, request: object): string[] {
39
78
  const encoded = Buffer.from(JSON.stringify(request)).toString('base64');
40
79
  const command = `cd -- ${shellQuote(node.workspace)} && export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH" && exec ${shellQuote(node.runtime ?? 'node')} --input-type=module - ${shellQuote(encoded)}`;
@@ -48,7 +87,7 @@ const FAILURES = [
48
87
  id: 'ssh:host-key',
49
88
  summary: 'SSH host key is unknown or changed.',
50
89
  action:
51
- 'Verify the host fingerprint out of band, then update known_hosts manually. Never disable host-key checking.',
90
+ 'The worker presented a host key other than the pinned one. Verify its fingerprint out of band, then pin the new key: devfleet fleet-node-replace --id ID --host-key "TYPE BASE64" --apply --yes, or change hostKey in its worker record. A worker reached through your own SSH alias is checked against your known_hosts instead. Never disable host-key checking.',
52
91
  },
53
92
  {
54
93
  pattern:
@@ -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
+ }