@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
@@ -0,0 +1,305 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { record } from './config';
5
+ import { DefinitionError, HOST_KEY } from './definition';
6
+
7
+ /** One worker as an infrastructure tool describes it, without naming the
8
+ * tool or the provider: schema/worker-record.schema.json. A file or command
9
+ * `workerRecords` in fleet.json names returns a list of them; dev-fleet only
10
+ * reads it and never creates a machine.
11
+ */
12
+ export interface WorkerRecord {
13
+ id: string;
14
+ /** The tailnet name the worker joins under; preferred to `address`. */
15
+ tailnetName?: string;
16
+ /** An address SSH reaches the worker at, when it has no tailnet name. */
17
+ address?: string;
18
+ user: string;
19
+ /** The port sshd listens on; 22 when absent. */
20
+ port?: number;
21
+ /** The SSH host public key, "TYPE BASE64": pinned, never learned.
22
+ * Without one the key is the one the fleet state pins (fleet-node-seed
23
+ * --apply --yes), and until then every session is refused.
24
+ */
25
+ hostKey?: string;
26
+ /** The port the worker's T3 server listens on; 3773 when absent. */
27
+ t3Port?: number;
28
+ labels?: Record<string, string>;
29
+ billing?: { hourlyRate: number; currency?: string; note?: string };
30
+ }
31
+
32
+ /** Where `workerRecords` reads records from. */
33
+ export type RecordSource = { file: string } | { command: string[] };
34
+
35
+ /** Runs a record command in the project directory; its standard output. */
36
+ export type RecordCommand = (
37
+ argv: string[],
38
+ directory: string
39
+ ) => string | null;
40
+
41
+ const ID = /^[a-z][a-z0-9-]{0,62}$/;
42
+ const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
43
+ const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
44
+ const RECORD_FIELDS = [
45
+ 'id',
46
+ 'tailnetName',
47
+ 'address',
48
+ 'user',
49
+ 'port',
50
+ 'hostKey',
51
+ 't3Port',
52
+ 'labels',
53
+ 'billing',
54
+ ];
55
+ /** Port of the worker T3 server when a record names none. */
56
+ const T3_PORT = 3773;
57
+
58
+ function invalid(
59
+ path: string,
60
+ expected: string,
61
+ actual: unknown,
62
+ code = actual === undefined ? 'DEFINITION_MISSING' : 'DEFINITION_INVALID'
63
+ ): never {
64
+ throw new DefinitionError({
65
+ code,
66
+ path,
67
+ expected,
68
+ actual:
69
+ actual === undefined
70
+ ? 'nothing'
71
+ : Array.isArray(actual)
72
+ ? `an array of ${actual.length}`
73
+ : record(actual)
74
+ ? 'an object'
75
+ : JSON.stringify(actual).slice(0, 64),
76
+ });
77
+ }
78
+
79
+ function string(
80
+ value: unknown,
81
+ pattern: RegExp,
82
+ path: string,
83
+ expected: string
84
+ ): string {
85
+ if (typeof value !== 'string' || !pattern.test(value))
86
+ invalid(path, expected, value);
87
+ return value as string;
88
+ }
89
+
90
+ function port(value: unknown, path: string): number {
91
+ if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535)
92
+ invalid(path, 'a port from 1 to 65535', value);
93
+ return value as number;
94
+ }
95
+
96
+ /** One record, or a DefinitionError naming the field that is wrong. */
97
+ export function parseRecord(value: unknown, path: string): WorkerRecord {
98
+ if (!record(value)) return invalid(path, 'an object', value);
99
+ const unknown = Object.keys(value).find(
100
+ (key) => !RECORD_FIELDS.includes(key)
101
+ );
102
+ if (unknown !== undefined)
103
+ invalid(
104
+ `${path}.${unknown}`,
105
+ `one of ${RECORD_FIELDS.join(', ')}`,
106
+ value[unknown],
107
+ 'DEFINITION_UNKNOWN'
108
+ );
109
+ const id = string(value['id'], ID, `${path}.id`, 'a lowercase id');
110
+ if (value['tailnetName'] === undefined && value['address'] === undefined)
111
+ invalid(`${path}.tailnetName`, 'a tailnet name, or an address', undefined);
112
+ return {
113
+ id,
114
+ ...(value['tailnetName'] === undefined
115
+ ? {}
116
+ : {
117
+ tailnetName: string(
118
+ value['tailnetName'],
119
+ HOST,
120
+ `${path}.tailnetName`,
121
+ 'a host name'
122
+ ),
123
+ }),
124
+ ...(value['address'] === undefined
125
+ ? {}
126
+ : {
127
+ address: string(
128
+ value['address'],
129
+ HOST,
130
+ `${path}.address`,
131
+ 'a host name or IP address'
132
+ ),
133
+ }),
134
+ user: string(value['user'], USER, `${path}.user`, 'a login name'),
135
+ ...(value['port'] === undefined
136
+ ? {}
137
+ : { port: port(value['port'], `${path}.port`) }),
138
+ ...(value['hostKey'] === undefined
139
+ ? {}
140
+ : {
141
+ hostKey: string(
142
+ value['hostKey'],
143
+ HOST_KEY,
144
+ `${path}.hostKey`,
145
+ 'one SSH public key, "TYPE BASE64"'
146
+ ),
147
+ }),
148
+ ...(value['t3Port'] === undefined
149
+ ? {}
150
+ : { t3Port: port(value['t3Port'], `${path}.t3Port`) }),
151
+ ...(value['labels'] === undefined
152
+ ? {}
153
+ : { labels: value['labels'] as Record<string, string> }),
154
+ ...(value['billing'] === undefined
155
+ ? {}
156
+ : { billing: value['billing'] as WorkerRecord['billing'] & object }),
157
+ };
158
+ }
159
+
160
+ /** A record list: `{ "version": 1, "workers": [...] }`. */
161
+ export function parseRecords(value: unknown, path: string): WorkerRecord[] {
162
+ if (!record(value)) return invalid(path, 'an object', value);
163
+ if (value['version'] !== 1) invalid(`${path}.version`, '1', value['version']);
164
+ const workers = value['workers'];
165
+ if (!Array.isArray(workers) || workers.length > 64)
166
+ return invalid(
167
+ `${path}.workers`,
168
+ 'an array of at most 64 records',
169
+ workers
170
+ );
171
+ return workers.map((entry, index) =>
172
+ parseRecord(entry, `${path}.workers[${index}]`)
173
+ );
174
+ }
175
+
176
+ /** The worker a record declares, as fleet.json would write it. Labels and
177
+ * billing are checked by the definition itself.
178
+ */
179
+ export function recordWorker(entry: WorkerRecord): Record<string, unknown> {
180
+ return {
181
+ id: entry.id,
182
+ os: 'linux',
183
+ network: {
184
+ tailscaleName: entry.tailnetName ?? entry.address,
185
+ user: entry.user,
186
+ sshPort: entry.port ?? 22,
187
+ t3Port: entry.t3Port ?? T3_PORT,
188
+ ...(entry.hostKey ? { hostKey: entry.hostKey } : {}),
189
+ },
190
+ ...(entry.labels ? { labels: entry.labels } : {}),
191
+ ...(entry.billing ? { billing: entry.billing } : {}),
192
+ origin: 'records',
193
+ };
194
+ }
195
+
196
+ function source(value: unknown, path: string): RecordSource {
197
+ if (record(value) && typeof value['file'] === 'string' && !value['command'])
198
+ return { file: value['file'] };
199
+ if (
200
+ record(value) &&
201
+ Array.isArray(value['command']) &&
202
+ value['command'].length > 0 &&
203
+ value['command'].every((entry) => typeof entry === 'string') &&
204
+ value['file'] === undefined
205
+ )
206
+ return { command: value['command'] as string[] };
207
+ return invalid(path, '{ "file": PATH } or { "command": [ARGV] }', value);
208
+ }
209
+
210
+ /** Runs a record command where fleet.json is, for at most a minute. */
211
+ export const runRecordCommand: RecordCommand = (argv, directory) => {
212
+ const result = spawnSync(argv[0]!, argv.slice(1), {
213
+ cwd: directory,
214
+ encoding: 'utf8',
215
+ timeout: 60_000,
216
+ maxBuffer: 1024 * 1024,
217
+ stdio: ['ignore', 'pipe', 'ignore'],
218
+ });
219
+ return result.status === 0 ? result.stdout : null;
220
+ };
221
+
222
+ function readSource(
223
+ entry: RecordSource,
224
+ path: string,
225
+ directory: string,
226
+ command: RecordCommand
227
+ ): unknown {
228
+ let text: string | null;
229
+ if ('file' in entry) {
230
+ try {
231
+ text = readFileSync(
232
+ isAbsolute(entry.file) ? entry.file : join(directory, entry.file),
233
+ 'utf8'
234
+ );
235
+ } catch (error) {
236
+ // The tool that writes the file removes it with the last machine
237
+ // (`tofu destroy`): no file is no record.
238
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT')
239
+ return { version: 1, workers: [] };
240
+ text = null;
241
+ }
242
+ } else text = command(entry.command, directory);
243
+ if (text === null)
244
+ invalid(
245
+ `${path}.${'file' in entry ? 'file' : 'command'}`,
246
+ 'a readable list of worker records',
247
+ 'nothing',
248
+ 'DEFINITION_UNREADABLE'
249
+ );
250
+ try {
251
+ return JSON.parse(text!) as unknown;
252
+ } catch {
253
+ return invalid(
254
+ path,
255
+ 'JSON',
256
+ 'text that is not JSON',
257
+ 'DEFINITION_UNREADABLE'
258
+ );
259
+ }
260
+ }
261
+
262
+ /** fleet.json with the workers its record sources return appended to the
263
+ * inline list, and `workerRecords` itself gone: what the rest of dev-fleet
264
+ * reads, installs and stores. A record whose ID is already declared is
265
+ * refused, naming the record.
266
+ */
267
+ export function expandRecords(
268
+ value: unknown,
269
+ directory: string,
270
+ command: RecordCommand = runRecordCommand
271
+ ): unknown {
272
+ if (!record(value) || value['workerRecords'] === undefined) return value;
273
+ const { workerRecords, ...rest } = value;
274
+ if (!Array.isArray(workerRecords) || workerRecords.length > 8)
275
+ return invalid(
276
+ '$.workerRecords',
277
+ 'a list of at most 8 record sources',
278
+ workerRecords
279
+ );
280
+ const inline = Array.isArray(rest['workers']) ? rest['workers'] : [];
281
+ const seen = new Set(
282
+ inline.map((entry) => (record(entry) ? entry['id'] : undefined))
283
+ );
284
+ const added: Record<string, unknown>[] = [];
285
+ workerRecords.forEach((raw, index) => {
286
+ const path = `$.workerRecords[${index}]`;
287
+ const entry = source(raw, path);
288
+ const records = parseRecords(
289
+ readSource(entry, path, directory, command),
290
+ path
291
+ );
292
+ records.forEach((found, position) => {
293
+ if (seen.has(found.id))
294
+ invalid(
295
+ `${path}.workers[${position}].id`,
296
+ 'an id no other worker has',
297
+ found.id,
298
+ 'DEFINITION_DUPLICATE'
299
+ );
300
+ seen.add(found.id);
301
+ added.push(recordWorker(found));
302
+ });
303
+ });
304
+ return { ...rest, workers: [...inline, ...added] };
305
+ }
@@ -25,8 +25,15 @@ export interface SeedInput {
25
25
  tailscale: { version: string; keySha256: string };
26
26
  /** The codename of the fleet's Ubuntu release, from the Ubuntu policy. */
27
27
  codename: string;
28
+ /** A self-hosted control server the machine joins; the hosted Tailscale's
29
+ * when absent.
30
+ */
31
+ loginServer?: string;
28
32
  }
29
33
 
34
+ /** The login server the Tailscale client uses when it is told none. */
35
+ const TAILSCALE_LOGIN_SERVER = 'https://controlplane.tailscale.com';
36
+
30
37
  const PUBLIC_KEY =
31
38
  /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}( [^\n]*)?$/;
32
39
 
@@ -48,7 +55,14 @@ export async function seedUserData(
48
55
  throw new FleetUsageError(
49
56
  'The manager public key is not one OpenSSH public key line.'
50
57
  );
51
- if (!/^tskey-auth-[A-Za-z0-9-]{8,200}$/.test(input.authKey))
58
+ // A Headscale key has no fixed prefix; the hosted Tailscale's does.
59
+ if (
60
+ !(
61
+ input.loginServer
62
+ ? /^[A-Za-z0-9_-]{16,256}$/
63
+ : /^tskey-auth-[A-Za-z0-9-]{8,200}$/
64
+ ).test(input.authKey)
65
+ )
52
66
  throw new FleetUsageError(
53
67
  'The tailnet-auth-key secret is not a Tailscale auth key (tskey-auth-…). Use a short-lived, single-use, tagged key.'
54
68
  );
@@ -62,6 +76,7 @@ export async function seedUserData(
62
76
  TAILSCALE_VERSION: input.tailscale.version,
63
77
  TAILSCALE_KEY_SHA256: input.tailscale.keySha256,
64
78
  CODENAME: input.codename,
79
+ LOGIN_SERVER: input.loginServer ?? TAILSCALE_LOGIN_SERVER,
65
80
  };
66
81
  const template = parse(
67
82
  await readFile(join(root, 'bootstrap/worker-seed.yaml'), 'utf8')
@@ -102,16 +117,6 @@ export function renderSeed(
102
117
  return `#cloud-config\n${stringify(document, { lineWidth: 0 })}`;
103
118
  }
104
119
 
105
- /** The version and key digest the worker baseline pins for Tailscale. */
106
- export async function tailscalePins(
107
- root = PACKAGE_ROOT
108
- ): Promise<SeedInput['tailscale']> {
109
- const policy = JSON.parse(
110
- await readFile(join(root, 'ansible/ubuntu/policy.json'), 'utf8')
111
- ) as { tailscale: SeedInput['tailscale'] };
112
- return policy.tailscale;
113
- }
114
-
115
120
  /** A new ed25519 host key, generated in a private directory that is removed
116
121
  * afterwards.
117
122
  */
@@ -0,0 +1,76 @@
1
+ import policy from '../ansible/ubuntu/policy.json';
2
+
3
+ /** How much of a machine a worker needs, and the lines its disk and memory
4
+ * policy draws, all declared under `sizing` in ansible/ubuntu/policy.json.
5
+ * Each line is a share of the disk or of memory between a lower and an upper
6
+ * bound, so it scales from a small rented instance to a large workstation.
7
+ * `tasks/sizing.yml` computes the same numbers for Ansible.
8
+ */
9
+ export type ThresholdName = keyof typeof policy.sizing.thresholds;
10
+
11
+ export interface MachineSize {
12
+ memoryMiB: number;
13
+ /** The size of the root filesystem (or the home one, for its own lines). */
14
+ diskMiB: number;
15
+ cpus: number;
16
+ }
17
+
18
+ /** One line in MiB for a machine whose disk or memory (as the line says)
19
+ * is `totalMiB`: its share, never under its lower bound or over its upper. */
20
+ export function threshold(name: ThresholdName, totalMiB: number): number {
21
+ const line = policy.sizing.thresholds[name];
22
+ return Math.min(
23
+ line.maxMiB,
24
+ Math.max(line.minMiB, Math.floor(line.share * totalMiB))
25
+ );
26
+ }
27
+
28
+ /** Every line, for a machine of this size. */
29
+ export function thresholds(size: {
30
+ memoryMiB: number;
31
+ diskMiB: number;
32
+ }): Record<ThresholdName, number> {
33
+ return Object.fromEntries(
34
+ (Object.keys(policy.sizing.thresholds) as ThresholdName[]).map((name) => [
35
+ name,
36
+ threshold(
37
+ name,
38
+ policy.sizing.thresholds[name].of === 'memory'
39
+ ? size.memoryMiB
40
+ : size.diskMiB
41
+ ),
42
+ ])
43
+ ) as Record<ThresholdName, number>;
44
+ }
45
+
46
+ export const MINIMUM_WORKER: MachineSize = policy.sizing.minimumWorker;
47
+
48
+ /** What a machine lacks to be a worker, each as `memory 2048 MiB < 3584
49
+ * MiB`; empty when it has the stated minimum. */
50
+ export function belowMinimum(size: MachineSize): string[] {
51
+ const lacks: string[] = [];
52
+ if (size.memoryMiB < MINIMUM_WORKER.memoryMiB)
53
+ lacks.push(
54
+ `memory ${size.memoryMiB} MiB < ${MINIMUM_WORKER.memoryMiB} MiB`
55
+ );
56
+ if (size.diskMiB < MINIMUM_WORKER.diskMiB)
57
+ lacks.push(`disk ${size.diskMiB} MiB < ${MINIMUM_WORKER.diskMiB} MiB`);
58
+ if (size.cpus < MINIMUM_WORKER.cpus)
59
+ lacks.push(`CPUs ${size.cpus} < ${MINIMUM_WORKER.cpus}`);
60
+ return lacks;
61
+ }
62
+
63
+ /** The user workloads' share of memory, from `userMemoryMax` ("90%"). */
64
+ function userShare(): number {
65
+ const percent = /^(\d{1,3})%$/.exec(policy.resourceSafety.userMemoryMax);
66
+ return percent ? Number(percent[1]) / 100 : 1;
67
+ }
68
+
69
+ /** How many agent sessions run side by side within the memory policy:
70
+ * what user workloads may use, less what T3 keeps for itself, in sessions
71
+ * of the estimated size `sizing.agentSessionMiB`. Never below zero.
72
+ */
73
+ export function supportedSessions(memoryMiB: number): number {
74
+ const usable = memoryMiB * userShare() - policy.sizing.t3ReservedMiB;
75
+ return Math.max(0, Math.floor(usable / policy.sizing.agentSessionMiB));
76
+ }
@@ -1,36 +0,0 @@
1
- import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
-
4
- /** The most a scheduled job's log may hold; a longer one keeps its newest
5
- * quarter.
6
- */
7
- export const SCHEDULE_LOG_BYTES = 1024 * 1024;
8
-
9
- /** Caps the schedule's own logs (`<label>.out.log`, `<label>.err.log`) at the
10
- * start of every scheduled run. launchd and systemd append to them, so the
11
- * newest lines are what is kept. Nothing else in the directory is touched.
12
- */
13
- export function capScheduleLogs(
14
- directory: string,
15
- limit = SCHEDULE_LOG_BYTES
16
- ): void {
17
- let names: string[];
18
- try {
19
- names = readdirSync(directory);
20
- } catch {
21
- return;
22
- }
23
- for (const name of names) {
24
- if (!/^[a-z0-9-]+\.(out|err)\.log$/.test(name)) continue;
25
- const path = join(directory, name);
26
- try {
27
- const stat = statSync(path);
28
- if (!stat.isFile() || stat.size <= limit) continue;
29
- const kept = readFileSync(path).subarray(stat.size - limit / 4);
30
- const start = kept.indexOf(10) + 1;
31
- writeFileSync(path, kept.subarray(start), { mode: 0o600 });
32
- } catch {
33
- // A log that cannot be capped is capped by the next run.
34
- }
35
- }
36
- }