@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,238 @@
1
+ import type { FleetDefinition, MachineDefinition } from '../definition';
2
+ import { FleetUsageError } from '../errors';
3
+ import { fleet } from '../fleet-config';
4
+ import { makeReport, renderReport } from '../report';
5
+ import { runCommand } from '../runner';
6
+ import { changeWorkers, readWorkerStates } from '../state/workers';
7
+ import { stateContext, type StateContext } from '../state/sync';
8
+ import type { Check, Runner } from '../types';
9
+ import { duration, workerActivity } from '../worker-activity';
10
+ import type { CommandOutcome } from './contracts';
11
+ import { readRoster } from './roster';
12
+
13
+ export interface NodeParkOptions {
14
+ id?: string;
15
+ /** Park although agent turns are under way, or cannot be counted. */
16
+ force?: boolean;
17
+ dryRun?: boolean;
18
+ }
19
+
20
+ export interface NodeParkServices {
21
+ run: Runner;
22
+ /** The roster: the fleet state's, or a file `--config` names. */
23
+ roster: string;
24
+ definition: () => FleetDefinition;
25
+ state: () => StateContext;
26
+ now: () => Date;
27
+ /** A normal fleet-sync of one worker, which verifies it. */
28
+ sync: (id: string) => Promise<CommandOutcome>;
29
+ }
30
+
31
+ export function parkServices(
32
+ roster: string,
33
+ sync: (id: string) => Promise<CommandOutcome>
34
+ ): NodeParkServices {
35
+ return {
36
+ run: runCommand,
37
+ roster,
38
+ definition: () => fleet,
39
+ state: () => stateContext(runCommand),
40
+ now: () => new Date(),
41
+ sync,
42
+ };
43
+ }
44
+
45
+ /** A hook may start or stop a machine through an infrastructure tool; it
46
+ * gets half an hour.
47
+ */
48
+ const HOOK_TIMEOUT_MS = 1_800_000;
49
+
50
+ function declared(definition: FleetDefinition, id: string | undefined) {
51
+ const worker = definition.workers.find((entry) => entry.id === id);
52
+ if (!worker)
53
+ throw new FleetUsageError('--id must name a worker fleet.json declares.');
54
+ return worker;
55
+ }
56
+
57
+ function outcome(
58
+ command: string,
59
+ id: string,
60
+ checks: Check[],
61
+ extra: Record<string, unknown> = {}
62
+ ): CommandOutcome {
63
+ const report = { ...makeReport(command, [{ node: id, checks }]), ...extra };
64
+ return {
65
+ exitCode: checks.some((check) => check.status === 'fail') ? 1 : 0,
66
+ data: report,
67
+ text: renderReport(report),
68
+ };
69
+ }
70
+
71
+ /** Runs the declared stop or start command with the worker ID last. */
72
+ async function hook(
73
+ worker: MachineDefinition,
74
+ which: 'stop' | 'start',
75
+ definition: FleetDefinition,
76
+ run: Runner
77
+ ): Promise<Check | null> {
78
+ const command = definition.parking?.[which];
79
+ if (!command) return null;
80
+ const result = await run([...command, worker.id], {
81
+ timeoutMs: HOOK_TIMEOUT_MS,
82
+ });
83
+ return result.ok
84
+ ? {
85
+ id: 'worker:park-hook',
86
+ status: 'pass',
87
+ summary: `The declared ${which} command ran for ${worker.id}.`,
88
+ }
89
+ : {
90
+ id: 'worker:park-hook',
91
+ status: 'fail',
92
+ summary:
93
+ which === 'stop'
94
+ ? 'WORKER_STOP_HOOK_FAILED'
95
+ : 'WORKER_START_HOOK_FAILED',
96
+ remedy: {
97
+ action: `The ${which} command parking.${which} in fleet.json names failed for ${worker.id}, so its state was not changed. Run it by hand to see why, then run this command again.`,
98
+ },
99
+ };
100
+ }
101
+
102
+ /** Why a worker must not be parked now, unless forced: agent turns under
103
+ * way, or a worker that answers but whose turns cannot be counted. A worker
104
+ * that does not answer is off already.
105
+ */
106
+ async function busy(
107
+ worker: MachineDefinition,
108
+ services: NodeParkServices
109
+ ): Promise<Check | null> {
110
+ const node = (await readRoster(services.roster))?.nodes.find(
111
+ (entry) => entry.id === worker.id
112
+ );
113
+ const activity = node ? await workerActivity(node, services.run) : null;
114
+ if (!activity || activity.runningSessions === 0) return null;
115
+ return {
116
+ id: 'worker:parked',
117
+ status: 'fail',
118
+ summary:
119
+ activity.runningSessions === null
120
+ ? 'WORKER_ACTIVITY_UNREADABLE'
121
+ : 'WORKER_BUSY',
122
+ remedy: {
123
+ action:
124
+ activity.runningSessions === null
125
+ ? `${worker.id}'s T3 could not say whether agent turns are under way, so it was not parked. Look at it, then repeat with --force to park it anyway.`
126
+ : `${worker.id} runs ${activity.runningSessions} agent turn(s); stopping it would end them. Wait for them to finish, or repeat with --force.`,
127
+ },
128
+ };
129
+ }
130
+
131
+ /** Parks a worker: it is stopped or destroyed on purpose, so it is no longer
132
+ * contacted, never the canary and does not fail the fleet. Refuses while
133
+ * agent turns run on it, unless --force. Runs the declared stop command.
134
+ */
135
+ export async function fleetNodePark(
136
+ options: NodeParkOptions,
137
+ services: NodeParkServices
138
+ ): Promise<CommandOutcome> {
139
+ const definition = services.definition();
140
+ const worker = declared(definition, options.id);
141
+ const parkedAt = readWorkerStates().workers[worker.id]?.parkedAt;
142
+ if (parkedAt)
143
+ return outcome('fleet-node-park', worker.id, [
144
+ {
145
+ id: 'worker:parked',
146
+ status: 'pass',
147
+ summary: `${worker.id} is parked since ${parkedAt}.`,
148
+ },
149
+ ]);
150
+ if (!options.force) {
151
+ const refused = await busy(worker, services);
152
+ if (refused) return outcome('fleet-node-park', worker.id, [refused]);
153
+ }
154
+ if (options.dryRun)
155
+ return outcome('fleet-node-park', worker.id, [
156
+ {
157
+ id: 'worker:parked',
158
+ status: 'skip',
159
+ summary: `Planned: ${definition.parking?.stop ? 'run the declared stop command and ' : ''}park ${worker.id}.`,
160
+ },
161
+ ]);
162
+ const stopped = await hook(worker, 'stop', definition, services.run);
163
+ if (stopped?.status === 'fail')
164
+ return outcome('fleet-node-park', worker.id, [stopped]);
165
+ const at = services.now().toISOString();
166
+ await changeWorkers(services.state(), (current) => ({
167
+ [worker.id]: { ...current[worker.id], parkedAt: at },
168
+ }));
169
+ return outcome('fleet-node-park', worker.id, [
170
+ ...(stopped ? [stopped] : []),
171
+ {
172
+ id: 'worker:parked',
173
+ status: 'pass',
174
+ summary: `${worker.id} is parked since ${at}: fleet-sync and fleet-status no longer contact it.`,
175
+ },
176
+ ]);
177
+ }
178
+
179
+ /** Brings a parked worker back: runs the declared start command, marks it
180
+ * active, then runs a normal fleet-sync of that worker, which verifies it.
181
+ * The exit code is the sync's.
182
+ */
183
+ export async function fleetNodeResume(
184
+ options: NodeParkOptions,
185
+ services: NodeParkServices
186
+ ): Promise<CommandOutcome> {
187
+ const definition = services.definition();
188
+ const worker = declared(definition, options.id);
189
+ const parkedAt = readWorkerStates().workers[worker.id]?.parkedAt;
190
+ if (options.dryRun)
191
+ return outcome('fleet-node-resume', worker.id, [
192
+ {
193
+ id: 'worker:parked',
194
+ status: 'skip',
195
+ summary: parkedAt
196
+ ? `Planned: ${definition.parking?.start ? 'run the declared start command, ' : ''}mark ${worker.id} active and run fleet-sync --node ${worker.id}.`
197
+ : `Planned: ${worker.id} is active already; run fleet-sync --node ${worker.id}.`,
198
+ },
199
+ ]);
200
+ const checks: Check[] = [];
201
+ if (parkedAt) {
202
+ const started = await hook(worker, 'start', definition, services.run);
203
+ if (started?.status === 'fail')
204
+ return outcome('fleet-node-resume', worker.id, [started]);
205
+ if (started) checks.push(started);
206
+ await changeWorkers(services.state(), (current) => {
207
+ const { parkedAt: _parked, ...rest } = current[worker.id] ?? {};
208
+ return { [worker.id]: rest };
209
+ });
210
+ const since = (services.now().getTime() - Date.parse(parkedAt)) / 1000;
211
+ checks.push({
212
+ id: 'worker:parked',
213
+ status: 'pass',
214
+ summary: `${worker.id} is active again after ${duration(Math.max(0, since))} parked.`,
215
+ });
216
+ }
217
+ const synced = await services.sync(worker.id);
218
+ const report = synced.data as { ok?: boolean };
219
+ checks.push(
220
+ synced.exitCode === 0 && report.ok !== false
221
+ ? {
222
+ id: 'rollout:node',
223
+ status: 'pass',
224
+ summary: `fleet-sync --node ${worker.id} converged and verified it.`,
225
+ }
226
+ : {
227
+ id: 'rollout:node',
228
+ status: 'fail',
229
+ summary: 'WORKER_RESUME_FAILED',
230
+ remedy: {
231
+ action: `fleet-sync --node ${worker.id} did not end ready; its report (sync in this output) says why. Fix that, then run devfleet fleet-sync --node ${worker.id}.`,
232
+ },
233
+ }
234
+ );
235
+ return outcome('fleet-node-resume', worker.id, checks, {
236
+ sync: synced.data,
237
+ });
238
+ }
@@ -0,0 +1,167 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import type { FleetDefinition } from '../definition';
5
+ import { tailscalePins, ubuntuRelease } from '../platform/ubuntu-release';
6
+ import { FleetUsageError } from '../errors';
7
+ import { fleet, secret } from '../fleet-config';
8
+ import type { SecretsProvider } from '../secrets';
9
+ import {
10
+ controlRemedy,
11
+ fleetTailnetControl,
12
+ loginServer,
13
+ TailnetControlError,
14
+ type ControlHttp,
15
+ } from '../tailnet-control';
16
+ import type { Check } from '../types';
17
+ import { generateHostKey, renderSeed, seedUserData } from '../worker-seed';
18
+ import type { CommandOutcome } from './contracts';
19
+ import {
20
+ keyName,
21
+ lifecycleServices,
22
+ pinHostKey,
23
+ type NodeLifecycleServices,
24
+ } from './fleet-node-lifecycle';
25
+
26
+ export interface NodeSeedOptions {
27
+ id?: string;
28
+ output?: string;
29
+ publicKey?: string;
30
+ autoinstall?: boolean;
31
+ apply?: boolean;
32
+ yes?: boolean;
33
+ }
34
+
35
+ export interface NodeSeedServices extends Pick<
36
+ NodeLifecycleServices,
37
+ 'run' | 'playbook' | 'knownHosts' | 'state' | 'now'
38
+ > {
39
+ definition: () => FleetDefinition;
40
+ secret: SecretsProvider;
41
+ home: string;
42
+ /** The tailnet control plane's HTTP; answered in scenarios. */
43
+ http?: ControlHttp;
44
+ }
45
+
46
+ export function seedServices(roster: string): NodeSeedServices {
47
+ const { run, playbook, knownHosts, state, now } = lifecycleServices(roster);
48
+ return {
49
+ run,
50
+ playbook,
51
+ knownHosts,
52
+ state,
53
+ now,
54
+ definition: () => fleet,
55
+ secret,
56
+ home: homedir(),
57
+ };
58
+ }
59
+
60
+ /** Writes the first-boot seed of a declared worker to a private file: the
61
+ * seed carries a single-use auth key, so it is never printed. With --apply
62
+ * --yes the manager pins the host key the seed installs, so the new machine
63
+ * is trusted without a first-use step.
64
+ */
65
+ export async function fleetNodeSeed(
66
+ options: NodeSeedOptions,
67
+ services: NodeSeedServices
68
+ ): Promise<CommandOutcome> {
69
+ const worker = services
70
+ .definition()
71
+ .workers.find((entry) => entry.id === options.id);
72
+ if (!worker)
73
+ throw new FleetUsageError('--id must name a worker fleet.json declares.');
74
+ if (!options.output)
75
+ throw new FleetUsageError(
76
+ '--output PATH is required: the seed holds a single-use auth key and is never printed.'
77
+ );
78
+ const keyFile =
79
+ options.publicKey ?? join(services.home, '.ssh/id_ed25519.pub');
80
+ const managerKey = await readFile(keyFile, 'utf8').catch(() => {
81
+ throw new FleetUsageError(
82
+ `No manager public key at ${keyFile}; name one with --public-key PATH.`
83
+ );
84
+ });
85
+ const tailnet = services.definition().tailnet;
86
+ const authKey = await seedAuthKey(worker.id, services);
87
+ const release = ubuntuRelease(services.definition().ubuntu.release);
88
+ if (!release)
89
+ throw new FleetUsageError(
90
+ 'fleet.json names an Ubuntu release the support matrix does not list.'
91
+ );
92
+ const hostKey = await generateHostKey(worker.id, services.run);
93
+ const seed = renderSeed(
94
+ await seedUserData({
95
+ worker,
96
+ managerKey,
97
+ authKey,
98
+ hostKey,
99
+ tailscale: tailscalePins(release),
100
+ codename: release.codename,
101
+ ...(loginServer(tailnet) ? { loginServer: loginServer(tailnet)! } : {}),
102
+ }),
103
+ Boolean(options.autoinstall)
104
+ );
105
+ await writeFile(options.output, seed, { mode: 0o600 });
106
+ const host = await keyName(worker, services.run);
107
+ const publicKey = hostKey.public.split(' ').slice(0, 2).join(' ');
108
+ // Pinned before the machine answers: in the fleet state for a worker
109
+ // reached from declared data, else in the operator's known_hosts.
110
+ const pin: Check =
111
+ options.apply && options.yes
112
+ ? await pinHostKey(worker, host, publicKey, services)
113
+ : {
114
+ id: 'ssh:host-key',
115
+ status: 'skip',
116
+ summary: `Planned: pin the seed's host key for ${host}; run again with --apply --yes.`,
117
+ };
118
+ return {
119
+ exitCode: pin.status === 'fail' ? 1 : 0,
120
+ data: {
121
+ version: 1,
122
+ command: 'fleet-node-seed',
123
+ ok: pin.status !== 'fail',
124
+ worker: worker.id,
125
+ output: options.output,
126
+ format: options.autoinstall ? 'autoinstall' : 'cloud-init',
127
+ hostKey: publicKey,
128
+ checks: [pin],
129
+ },
130
+ text: [
131
+ `Wrote the ${options.autoinstall ? 'autoinstall' : 'cloud-init'} seed of ${worker.id} to ${options.output} (private; it holds a single-use auth key).`,
132
+ `Host key: ${publicKey}`,
133
+ `${pin.status.toUpperCase()} ${pin.id}: ${pin.summary}`,
134
+ 'Boot a stock Ubuntu image with it; once the machine is on the tailnet, add it with fleet-node-add and run devfleet fleet-sync --node ' +
135
+ worker.id +
136
+ '.',
137
+ ].join('\n'),
138
+ };
139
+ }
140
+
141
+ /** The key the seed carries: one minted for this worker by the control plane
142
+ * fleet.json declares, or else the secret tailnet-auth-key.
143
+ */
144
+ async function seedAuthKey(
145
+ worker: string,
146
+ services: NodeSeedServices
147
+ ): Promise<string> {
148
+ const config = services.definition().tailnet;
149
+ if (config)
150
+ try {
151
+ const control = await fleetTailnetControl(
152
+ config,
153
+ services.secret,
154
+ services.http
155
+ );
156
+ return (await control!.createAuthKey({ worker })).key;
157
+ } catch (error) {
158
+ if (!(error instanceof TailnetControlError)) throw error;
159
+ throw new FleetUsageError(`${error.code}: ${controlRemedy(error.code)}`);
160
+ }
161
+ const key = await services.secret('tailnet-auth-key');
162
+ if (!key)
163
+ throw new FleetUsageError(
164
+ 'The secrets provider holds no tailnet-auth-key. Declare tailnet in fleet.json with the secret tailnet-admin so a key is minted per worker, or store a short-lived, single-use, tagged Tailscale auth key there first.'
165
+ );
166
+ return key;
167
+ }
@@ -486,13 +486,14 @@ async function restore(
486
486
  options: FleetStateOptions,
487
487
  services: FleetStateServices
488
488
  ): Promise<CommandOutcome> {
489
- const target = context(services);
489
+ const target = context(services, options);
490
490
  if (!target.bucket)
491
491
  throw new FleetUsageError(
492
492
  'No bucket is configured, so there is nothing to restore from. Name it under "state" in fleet.json, or run devfleet fleet-state --init --bucket NAME --endpoint https://HOST.'
493
493
  );
494
494
  if (options.timestamp !== undefined) {
495
495
  // The bucket's current version is replaced too: --yes is the consent.
496
+ // Another manager's lease is not, and needs --take-over.
496
497
  if (!options.yes)
497
498
  throw new FleetUsageError(
498
499
  '--restore --timestamp replaces the fleet state in the bucket and on this machine with an earlier version. Repeat with --yes.'
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
3
4
  import { join } from 'node:path';
4
5
  import { prepareSource } from '../machine/source';
5
6
  import { PACKAGE_ROOT, packageInfo } from '../package-info';
@@ -16,6 +17,12 @@ export const RUN_CHECKOUT_ENV = 'PERSONAL_FLEET_RUN_CHECKOUT';
16
17
 
17
18
  const RUN_SCRIPT = 'bootstrap/fleet-manager-run.sh';
18
19
 
20
+ /** Where the run script keeps the manager's fleet-owned checkouts, one per
21
+ * revision (`$fleet/manager-runs/<revision>`).
22
+ */
23
+ export const managerRuns = (): string =>
24
+ join(homedir(), '.local/share/personal/fleet/manager-runs');
25
+
19
26
  export const inRunCheckout = (revision: string): boolean =>
20
27
  process.env[RUN_CHECKOUT_ENV] === revision;
21
28