@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
@@ -1,4 +1,5 @@
1
- import { homedir } from 'node:os';
1
+ import { statSync } from 'node:fs';
2
+ import { homedir, userInfo } from 'node:os';
2
3
  import { join } from 'node:path';
3
4
  import macos from '../ansible/macos/policy.json';
4
5
  import type { Check, Runner } from './types';
@@ -9,16 +10,161 @@ import type { Check, Runner } from './types';
9
10
  */
10
11
  export async function managerHealth(
11
12
  run: Runner,
12
- home = homedir()
13
+ home = homedir(),
14
+ { uid, user }: { uid: number; user: string } = {
15
+ uid: userInfo().uid,
16
+ user: userInfo().username,
17
+ }
13
18
  ): Promise<Check[]> {
14
19
  return [
15
20
  await managerRelease(run),
16
21
  await managerUpdates(run),
17
22
  await managerDisk(run),
18
23
  await containerWatchdog(run, home),
24
+ await managerClock(run),
25
+ await managerWatch(run, uid),
26
+ await managerJobLogs(run, home, user),
19
27
  ];
20
28
  }
21
29
 
30
+ /** The one grant every administrator step of the manager is given with. */
31
+ const GRANT =
32
+ 'ansible-playbook -e user=$(id -un) -K ansible/macos/manager-privilege.yml';
33
+
34
+ /** The manager keeps network time: every worker's clock is measured against
35
+ * it. Reading the setting needs root, through the grant manager-privilege.yml
36
+ * installs; manager.yml asserts the same reading.
37
+ */
38
+ export async function managerClock(run: Runner): Promise<Check> {
39
+ const result = await run(
40
+ ['sudo', '-n', '/usr/sbin/systemsetup', '-getusingnetworktime'],
41
+ { timeoutMs: 10_000 }
42
+ );
43
+ const on = /Network Time:\s*On\b/i.test(result.stdout);
44
+ const off = /Network Time:\s*Off\b/i.test(result.stdout);
45
+ if (!result.ok || (!on && !off))
46
+ return {
47
+ id: 'manager:clock',
48
+ status: 'warn',
49
+ summary: 'MANAGER_CLOCK_UNOBSERVED',
50
+ remedy: {
51
+ action: `Reading whether network time is on needs the manager's grant. Give it once, as an administrator, from the dev-fleet checkout fleet-sync runs: ${GRANT}`,
52
+ },
53
+ };
54
+ if (on)
55
+ return {
56
+ id: 'manager:clock',
57
+ status: 'pass',
58
+ summary: 'The manager keeps its clock on network time.',
59
+ };
60
+ return {
61
+ id: 'manager:clock',
62
+ status: 'fail',
63
+ summary: 'MANAGER_CLOCK_NOT_SYNCHRONISED',
64
+ remedy: {
65
+ action:
66
+ 'Turn on Set time and date automatically in System Settings > General > Date & Time (sudo systemsetup -setusingnetworktime on). Every worker clock is measured against the manager.',
67
+ },
68
+ };
69
+ }
70
+
71
+ /** fleet-watch runs on a schedule as the launchd job manager.yml installs:
72
+ * without it no heartbeat is stored and no notification is sent.
73
+ */
74
+ export async function managerWatch(run: Runner, uid: number): Promise<Check> {
75
+ const label = macos.fleetWatch.label;
76
+ const result = await run(['/bin/launchctl', 'print', `gui/${uid}/${label}`], {
77
+ timeoutMs: 10_000,
78
+ });
79
+ if (!result.ok)
80
+ return {
81
+ id: 'manager:watch',
82
+ status: 'fail',
83
+ summary: 'MANAGER_WATCH_NOT_LOADED',
84
+ remedy: {
85
+ action: `Run devfleet fleet-sync on the manager: it installs and loads ${label} with ansible/macos/manager.yml. Until then no heartbeat reaches the bucket, and the heartbeat workflow fails.`,
86
+ },
87
+ };
88
+ // launchd keeps how the last run ended; a job that never ran has none.
89
+ const exit = /last exit code = (\d+)/.exec(result.stdout)?.[1];
90
+ if (exit !== undefined && exit !== '0')
91
+ return {
92
+ id: 'manager:watch',
93
+ status: 'warn',
94
+ summary: `MANAGER_WATCH_FAILING exit ${exit}`,
95
+ remedy: {
96
+ action: `Its last run failed. Read ~/${macos.jobLogs.directory}/${label}.err.log, and run devfleet fleet-watch on the manager to see the same failure.`,
97
+ },
98
+ };
99
+ return {
100
+ id: 'manager:watch',
101
+ status: 'pass',
102
+ summary: `fleet-watch runs on a schedule (${label}).`,
103
+ };
104
+ }
105
+
106
+ /** The logs of the manager's scheduled jobs, the size newsyslog keeps them
107
+ * under, and the drop-in manager-privilege.yml installs for it.
108
+ */
109
+ export function jobLogs(home: string): string[] {
110
+ return [macos.fleetSync.label, macos.fleetWatch.label].flatMap((label) =>
111
+ ['out', 'err'].map((stream) =>
112
+ join(home, macos.jobLogs.directory, `${label}.${stream}.log`)
113
+ )
114
+ );
115
+ }
116
+
117
+ export const newsyslogDropIn = (user: string): string =>
118
+ `/etc/newsyslog.d/dev-fleet-${user}.conf`;
119
+
120
+ /** The logs of the scheduled jobs stay under a fixed size: the newsyslog
121
+ * drop-in that rotates them is in place, names each of them, and none has
122
+ * grown past twice the size it is rotated at (newsyslog runs hourly).
123
+ */
124
+ export async function managerJobLogs(
125
+ run: Runner,
126
+ home: string,
127
+ user: string
128
+ ): Promise<Check> {
129
+ const logs = jobLogs(home);
130
+ const dropIn = await run(['cat', newsyslogDropIn(user)]);
131
+ const declared =
132
+ dropIn.ok &&
133
+ dropIn.stdout.includes('# Managed by dev-fleet: manager job logs') &&
134
+ logs.every((log) => dropIn.stdout.includes(`${log} `));
135
+ if (!declared)
136
+ return {
137
+ id: 'manager:job-logs',
138
+ status: 'warn',
139
+ summary: 'MANAGER_JOB_LOG_ROTATION_MISSING',
140
+ remedy: {
141
+ action: `newsyslog keeps the logs of the scheduled jobs under ${macos.jobLogs.maxKiB} KiB once the manager's grant installs its drop-in. Give it once, as an administrator, from the dev-fleet checkout fleet-sync runs: ${GRANT}`,
142
+ },
143
+ };
144
+ const limit = 2 * macos.jobLogs.maxKiB * 1024;
145
+ const large = logs.filter((log) => {
146
+ try {
147
+ return statSync(log).size > limit;
148
+ } catch {
149
+ return false;
150
+ }
151
+ });
152
+ if (large.length)
153
+ return {
154
+ id: 'manager:job-logs',
155
+ status: 'warn',
156
+ summary: `MANAGER_JOB_LOG_OVERSIZED ${large.length} logs`,
157
+ remedy: {
158
+ action: `newsyslog rotates these logs once they pass ${macos.jobLogs.maxKiB} KiB but has not: check that it runs (sudo newsyslog -nv -f ${newsyslogDropIn(user)}) and read /var/log/system.log for its errors.`,
159
+ },
160
+ };
161
+ return {
162
+ id: 'manager:job-logs',
163
+ status: 'pass',
164
+ summary: `newsyslog keeps the scheduled jobs' logs under ${macos.jobLogs.maxKiB} KiB.`,
165
+ };
166
+ }
167
+
22
168
  /** The manager runs at least the macOS release the policy names. */
23
169
  export async function managerRelease(run: Runner): Promise<Check> {
24
170
  const id = 'manager:release';
@@ -12,14 +12,19 @@ import {
12
12
  type TailnetComputer,
13
13
  } from './tailscale';
14
14
  import { matchComputers } from './tailnet-matching';
15
+ import { rebootStopsRollout } from './worker-reboot';
15
16
  import { sshArgs, sshFailure } from './ssh';
16
17
  import type { FleetDefinition, MachineDefinition } from './definition';
17
18
  import { publicHubUrl } from './monitoring/beszel';
18
19
  import {
19
20
  evaluateEnvironment,
21
+ evaluateMachineClass,
20
22
  evaluatePlatform,
23
+ evaluateSize,
21
24
  PLATFORM_PROBE,
25
+ probedClock,
22
26
  } from './machine/platform';
27
+ import { clockOffsetCheck } from './clock';
23
28
 
24
29
  export interface ManagerNode extends NodeReport {
25
30
  /** The singleton manager converges locally; workers over SSH. */
@@ -39,6 +44,10 @@ export interface ManagerReport {
39
44
  desiredRevision: string | null;
40
45
  /** The worker that proved a revision new to the fleet first, when one did. */
41
46
  canary?: string;
47
+ /** The workers this run enrolled (added to the roster, or bound to their
48
+ * tailnet device).
49
+ */
50
+ enrolled?: string[];
42
51
  checks: Check[];
43
52
  nodes: ManagerNode[];
44
53
  candidates: number;
@@ -49,17 +58,30 @@ export type DeployNode = (
49
58
  revision: string
50
59
  ) => Promise<NodeReport>;
51
60
 
52
- /** Minimal read-only POSIX inspection requires no workspace, Node, Bun or Git. */
61
+ /** Minimal read-only POSIX inspection requires no workspace, Node, Bun or Git.
62
+ * Given the manager's clock, it also measures the worker's clock against it
63
+ * (`worker:clock-offset`).
64
+ */
53
65
  export async function inspectBootstrap(
54
66
  node: FleetNode,
55
- run: Runner
67
+ run: Runner,
68
+ clock?: () => number
56
69
  ): Promise<Check[]> {
70
+ const sentAt = clock?.();
57
71
  const result = await run([...sshArgs(node), PLATFORM_PROBE], {
58
72
  timeoutMs: 20_000,
59
73
  });
74
+ const receivedAt = clock?.();
60
75
  if (!result.ok) return [sshFailure(result)];
61
76
  const baseline = evaluatePlatform(result.stdout);
62
77
  const environment = evaluateEnvironment(result.stdout);
78
+ const remote = probedClock(result.stdout);
79
+ const offset =
80
+ remote === undefined || sentAt === undefined || receivedAt === undefined
81
+ ? []
82
+ : [clockOffsetCheck(remote, sentAt, receivedAt)];
83
+ const machine = evaluateMachineClass(result.stdout);
84
+ const size = evaluateSize(result.stdout);
63
85
  return [
64
86
  {
65
87
  id: 'ssh:fresh-session',
@@ -85,6 +107,25 @@ export async function inspectBootstrap(
85
107
  'Run the worker on a physical or virtual machine of its own; docs/support-matrix.md lists what is supported.',
86
108
  },
87
109
  },
110
+ { id: machine.id, status: 'pass', summary: machine.summary },
111
+ ...(size
112
+ ? [
113
+ {
114
+ id: size.id,
115
+ status: size.supported ? ('pass' as const) : ('fail' as const),
116
+ summary: size.summary,
117
+ ...(size.supported
118
+ ? {}
119
+ : {
120
+ remedy: {
121
+ action:
122
+ 'Resize the machine to at least the minimum worker size docs/worker-contract.md states, then run fleet-sync. Nothing was installed.',
123
+ },
124
+ }),
125
+ },
126
+ ]
127
+ : []),
128
+ ...offset,
88
129
  ];
89
130
  }
90
131
 
@@ -98,11 +139,17 @@ export async function synchronizeManager(
98
139
  revision: string;
99
140
  apply: boolean;
100
141
  canary?: string;
142
+ /** One worker at a time, canary first; a worker a reboot left unready
143
+ * stops the ones after it.
144
+ */
145
+ serial?: boolean;
101
146
  progress?: (node: string, phase: string) => void;
102
147
  /** Called once per node as soon as its result is final. */
103
148
  finished?: (node: ManagerNode) => void;
104
149
  /** Converges the manager locally; omitted in tests. */
105
150
  manager?: () => Promise<ManagerNode>;
151
+ /** The manager's clock, in milliseconds; workers are measured against it. */
152
+ clock?: () => number;
106
153
  },
107
154
  run: Runner,
108
155
  deploy: DeployNode
@@ -146,6 +193,7 @@ export async function synchronizeManager(
146
193
  revision: input.revision,
147
194
  computers: tailnet.computers,
148
195
  matches,
196
+ clock: input.clock ?? Date.now,
149
197
  },
150
198
  run
151
199
  );
@@ -192,6 +240,7 @@ async function applyNodes(
192
240
  nodes: FleetNode[];
193
241
  revision: string;
194
242
  canary?: string;
243
+ serial?: boolean;
195
244
  progress?: (node: string, phase: string) => void;
196
245
  finished?: (node: ManagerNode) => void;
197
246
  },
@@ -216,7 +265,16 @@ async function applyNodes(
216
265
  const deployable = await step(canary);
217
266
  if (!deployable || canary.checks.some(blocksPromotion)) blocked = true;
218
267
  }
219
- await Promise.all(workers.filter((node) => node !== canary).map(step));
268
+ const rest = workers.filter((node) => node !== canary);
269
+ if (!input.serial) {
270
+ await Promise.all(rest.map(step));
271
+ return;
272
+ }
273
+ if (canary && rebootStopsRollout(canary)) blocked = true;
274
+ for (const node of rest) {
275
+ await step(node);
276
+ if (rebootStopsRollout(node)) blocked = true;
277
+ }
220
278
  }
221
279
 
222
280
  /** A revision new to the fleet is proven on one worker first: the preferred
@@ -322,6 +380,7 @@ async function inspectManagerNode(
322
380
  revision: string;
323
381
  computers: TailnetComputer[];
324
382
  matches: Map<string, FleetNode[]>;
383
+ clock: () => number;
325
384
  },
326
385
  run: Runner
327
386
  ): Promise<ManagerNode> {
@@ -357,7 +416,9 @@ async function inspectManagerNode(
357
416
  },
358
417
  });
359
418
  else {
360
- checks.push(...(await inspectBootstrap(node, run)));
419
+ // fleet-sync runs on the manager: the worker's clock is measured
420
+ // against the manager's own.
421
+ checks.push(...(await inspectBootstrap(node, run, context.clock)));
361
422
  checks.push(...identityChecks(node, peer));
362
423
  if (peer) checks.push(keyExpiryCheck(peer));
363
424
  }
@@ -27,7 +27,7 @@ function check(
27
27
  summary: string,
28
28
  action: string
29
29
  ): Check {
30
- return { id: `beszel:${id}`, status, summary, remedy: { action } };
30
+ return { id, status, summary, remedy: { action } };
31
31
  }
32
32
  /** Public dashboard hostname served through the Cloudflare
33
33
  * tunnel `beszel`. Agents and the manager keep using the private hub URL.
@@ -68,7 +68,7 @@ export async function verifyBeszel(
68
68
  if (!object(info) || info['v'] !== lock.version) {
69
69
  result.checks.push(
70
70
  check(
71
- 'version',
71
+ 'beszel:version',
72
72
  'fail',
73
73
  'BESZEL_VERSION_MISMATCH',
74
74
  'Apply the pinned hub release before using its versioned API contract.'
@@ -78,7 +78,7 @@ export async function verifyBeszel(
78
78
  }
79
79
  result.checks.push(
80
80
  check(
81
- 'version',
81
+ 'beszel:version',
82
82
  'pass',
83
83
  `Authenticated hub reports ${lock.version}.`,
84
84
  'None.'
@@ -93,7 +93,7 @@ export async function verifyBeszel(
93
93
  if (records.length !== 1) {
94
94
  result.checks.push(
95
95
  check(
96
- 'registration',
96
+ 'beszel:registration',
97
97
  'fail',
98
98
  records.length ? 'BESZEL_DUPLICATE_NODE' : 'BESZEL_NODE_MISSING',
99
99
  'Review the canonical node in the hub. Preserve its fingerprint and history; do not create a replacement identity.'
@@ -105,7 +105,7 @@ export async function verifyBeszel(
105
105
  } catch {
106
106
  result.checks.push(
107
107
  check(
108
- 'api',
108
+ 'beszel:api',
109
109
  'fail',
110
110
  'BESZEL_API_UNVERIFIED',
111
111
  'Verify private reachability and a valid hub account with access to this node. Raw HTTP bodies are withheld.'
@@ -147,7 +147,7 @@ async function verifySample(
147
147
  throw new Error('BESZEL_PROTOCOL');
148
148
  result.checks.push(
149
149
  check(
150
- 'registration',
150
+ 'beszel:registration',
151
151
  'pass',
152
152
  'Exactly one canonical node record is visible to this account.',
153
153
  'None.'
@@ -155,7 +155,7 @@ async function verifySample(
155
155
  );
156
156
  result.checks.push(
157
157
  check(
158
- 'connection',
158
+ 'beszel:connection',
159
159
  record['status'] === 'up' ? 'pass' : 'fail',
160
160
  record['status'] === 'up'
161
161
  ? 'Hub reports node connected.'
@@ -187,7 +187,7 @@ async function checkFreshness(
187
187
  : null;
188
188
  result.checks.push(
189
189
  check(
190
- 'freshness',
190
+ 'beszel:freshness',
191
191
  fresh ? 'pass' : 'fail',
192
192
  fresh
193
193
  ? `Hub received sample ${result.receivedAt}; within 180 seconds.`
@@ -3,7 +3,14 @@ import { record } from '../config';
3
3
  import { privateHub } from './beszel';
4
4
  import lock from '../../ansible/monitoring/lock.json';
5
5
 
6
- export type HubRequest = (path: string, body?: object) => Promise<unknown>;
6
+ /** A call to the hub's API: GET without a body, POST with one, or the
7
+ * method named (PATCH a record, DELETE one).
8
+ */
9
+ export type HubRequest = (
10
+ path: string,
11
+ body?: object,
12
+ method?: 'PATCH' | 'DELETE'
13
+ ) => Promise<unknown>;
7
14
  export interface AgentEnrollment {
8
15
  systemId: string;
9
16
  hubUrl: string;
@@ -71,7 +71,7 @@ export async function hubAccess(
71
71
  url,
72
72
  '/api/collections/users/auth-with-password',
73
73
  '',
74
- credentials
74
+ { body: credentials }
75
75
  );
76
76
  if (
77
77
  !record(login) ||
@@ -83,24 +83,30 @@ export async function hubAccess(
83
83
  const token = login['token'];
84
84
  return {
85
85
  userId: login['record']['id'],
86
- request: (path, body) => call(url, path, token, body),
86
+ request: (path, body, method) =>
87
+ call(url, path, token, {
88
+ ...(body ? { body } : {}),
89
+ ...(method ? { method } : {}),
90
+ }),
87
91
  };
88
92
  }
89
93
  async function call(
90
94
  url: string,
91
95
  path: string,
92
96
  token: string,
93
- body?: object
97
+ { body, method }: { body?: object; method?: 'PATCH' | 'DELETE' } = {}
94
98
  ): Promise<unknown> {
95
99
  if (!path.startsWith('/api/')) throw new Error('BESZEL_API_PATH');
96
100
  const response = await fetch(`${url}${path}`, {
97
- method: body ? 'POST' : 'GET',
101
+ method: method ?? (body ? 'POST' : 'GET'),
98
102
  headers: { Authorization: token, 'Content-Type': 'application/json' },
99
103
  ...(body ? { body: JSON.stringify(body) } : {}),
100
104
  redirect: 'error',
101
105
  signal: AbortSignal.timeout(10_000),
102
106
  });
103
107
  if (!response.ok) throw new Error(failure(response.status, path));
108
+ // PocketBase answers a deletion with no content.
109
+ if (response.status === 204) return null;
104
110
  return response.json() as Promise<unknown>;
105
111
  }
106
112
 
@@ -0,0 +1,138 @@
1
+ import { record } from '../config';
2
+ import type { Check } from '../types';
3
+ import type { HubRequest } from './enroll';
4
+
5
+ /** The hub keeps one system per worker, named by its fleet ID. These change
6
+ * that list when a worker is replaced or retired, and find the systems no
7
+ * declared worker matches. Only names and hosts are read; a record's other
8
+ * fields (tokens, users) are never returned.
9
+ */
10
+ export interface HubSystem {
11
+ id: string;
12
+ name: string;
13
+ host: string;
14
+ }
15
+
16
+ function systems(value: unknown): HubSystem[] {
17
+ if (!record(value) || !Array.isArray(value['items']))
18
+ throw new Error('BESZEL_PROTOCOL');
19
+ return value['items'].flatMap((item) =>
20
+ record(item) &&
21
+ typeof item['id'] === 'string' &&
22
+ /^[a-z0-9]{15}$/.test(item['id']) &&
23
+ typeof item['name'] === 'string'
24
+ ? [
25
+ {
26
+ id: item['id'],
27
+ name: item['name'],
28
+ host: typeof item['host'] === 'string' ? item['host'] : '',
29
+ },
30
+ ]
31
+ : []
32
+ );
33
+ }
34
+
35
+ export async function listSystems(request: HubRequest): Promise<HubSystem[]> {
36
+ return systems(
37
+ await request(
38
+ '/api/collections/systems/records?perPage=500&fields=id,name,host'
39
+ )
40
+ );
41
+ }
42
+
43
+ async function named(request: HubRequest, node: string): Promise<HubSystem[]> {
44
+ return systems(
45
+ await request(
46
+ `/api/collections/systems/records?filter=${encodeURIComponent(`name="${node}"`)}&perPage=2&fields=id,name,host`
47
+ )
48
+ );
49
+ }
50
+
51
+ const check = (status: Check['status'], summary: string, action?: string) => ({
52
+ id: 'beszel:system',
53
+ status,
54
+ summary,
55
+ ...(action ? { remedy: { action } } : {}),
56
+ });
57
+
58
+ /** Points a replaced worker's system at the host it is reached by now, so
59
+ * the hub keeps one system for the ID and its history.
60
+ */
61
+ export async function moveSystem(
62
+ request: HubRequest,
63
+ node: string,
64
+ host: string
65
+ ): Promise<Check> {
66
+ const found = await named(request, node);
67
+ if (found.length > 1)
68
+ return check(
69
+ 'fail',
70
+ 'BESZEL_DUPLICATE_NODE',
71
+ `The hub holds more than one system named ${node}. Delete the extra ones in the dashboard, then run the command again.`
72
+ );
73
+ if (!found[0])
74
+ return check(
75
+ 'pass',
76
+ `The hub holds no system for ${node}; fleet-sync enrolls the new machine.`
77
+ );
78
+ if (found[0].host === host)
79
+ return check('pass', `The hub's system ${node} already watches ${host}.`);
80
+ await request(
81
+ `/api/collections/systems/records/${found[0].id}`,
82
+ { host },
83
+ 'PATCH'
84
+ );
85
+ return check('pass', `The hub's system ${node} now watches ${host}.`);
86
+ }
87
+
88
+ /** Deletes a retired worker's system and its agent fingerprint. */
89
+ export async function removeSystem(
90
+ request: HubRequest,
91
+ node: string
92
+ ): Promise<Check> {
93
+ const found = await named(request, node);
94
+ for (const system of found) {
95
+ const prints = await request(
96
+ `/api/collections/fingerprints/records?filter=${encodeURIComponent(`system="${system.id}"`)}&perPage=10&fields=id`
97
+ );
98
+ for (const print of record(prints) && Array.isArray(prints['items'])
99
+ ? prints['items']
100
+ : [])
101
+ if (record(print) && typeof print['id'] === 'string')
102
+ await request(
103
+ `/api/collections/fingerprints/records/${print['id']}`,
104
+ undefined,
105
+ 'DELETE'
106
+ );
107
+ await request(
108
+ `/api/collections/systems/records/${system.id}`,
109
+ undefined,
110
+ 'DELETE'
111
+ );
112
+ }
113
+ return check(
114
+ 'pass',
115
+ found.length
116
+ ? `The hub no longer watches ${node}.`
117
+ : `The hub holds no system for ${node}.`
118
+ );
119
+ }
120
+
121
+ /** A system no declared worker (and not the manager) is named after: what a
122
+ * worker removed by hand, or destroyed outside dev-fleet, leaves behind.
123
+ */
124
+ export function leftoverSystems(
125
+ found: HubSystem[],
126
+ declared: string[]
127
+ ): Check[] {
128
+ return found
129
+ .filter((system) => !declared.includes(system.name))
130
+ .map((system) => ({
131
+ id: 'beszel:leftover',
132
+ status: 'warn' as const,
133
+ summary: `The hub watches ${system.name}, which is no declared worker.`,
134
+ remedy: {
135
+ action: `If ${system.name} left the fleet, retire it (devfleet fleet-node-retire --id ${system.name} --apply --yes while it is still declared) or delete the system in the dashboard.`,
136
+ },
137
+ }));
138
+ }
@@ -1,4 +1,8 @@
1
- import { type FleetDefinition, type MachineDefinition } from './definition';
1
+ import {
2
+ sshTarget,
3
+ type FleetDefinition,
4
+ type MachineDefinition,
5
+ } from './definition';
2
6
  import { record } from './config';
3
7
  import { FleetUsageError } from './errors';
4
8
 
@@ -19,8 +23,11 @@ export function networkProjection(
19
23
  node.network
20
24
  ? [
21
25
  {
22
- alias: node.ssh,
23
- ...node.network,
26
+ alias: sshTarget(node),
27
+ tailscaleName: node.network.tailscaleName,
28
+ user: node.network.user,
29
+ sshPort: node.network.sshPort,
30
+ t3Port: node.network.t3Port,
24
31
  os: node.os,
25
32
  identityFile: identities[node.id] ?? '',
26
33
  },
@@ -34,7 +41,9 @@ function compatible(
34
41
  ): node is MachineDefinition {
35
42
  return Boolean(
36
43
  node?.network &&
37
- Object.entries(node.network).every(([key, value]) => host[key] === value) &&
44
+ Object.entries(node.network)
45
+ .filter(([key]) => key !== 'hostKey')
46
+ .every(([key, value]) => host[key] === value) &&
38
47
  node.os === host['os']
39
48
  );
40
49
  }