@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,129 @@
1
+ import { playbookConnection } from './ssh';
2
+ import type { PackagePlaybook } from './platform/ansible';
3
+ import { blocking, type Check, type FleetNode } from './types';
4
+
5
+ /** Why fleet-sync reboots a worker: its first converge, which nothing can be
6
+ * running on yet, or a maintenance reboot the operator asked for
7
+ * (`--maintenance-reboot`) to apply installed updates.
8
+ */
9
+ export type RebootReason = 'first-converge' | 'maintenance';
10
+
11
+ /** The boot-policy codes a reboot settles: the managed power files were
12
+ * written after this boot, or the running login manager has not loaded them.
13
+ */
14
+ const SETTLED_BY_REBOOT = [
15
+ 'UBUNTU_MAINTENANCE_REBOOT_PENDING',
16
+ 'UBUNTU_SESSION_POLICY_PENDING',
17
+ ];
18
+
19
+ /** The converge ran (its bootstrap passed) and says a reboot is needed. */
20
+ export function rebootNeeded(checks: Check[]): boolean {
21
+ if (
22
+ checks.some((check) => check.id.startsWith('bootstrap:') && blocking(check))
23
+ )
24
+ return false;
25
+ return checks.some(
26
+ (check) =>
27
+ (check.id === 'ubuntu:boot-policy' &&
28
+ SETTLED_BY_REBOOT.includes(check.summary)) ||
29
+ (check.id === 'worker:reboot-pending' && check.status !== 'pass')
30
+ );
31
+ }
32
+
33
+ /** A maintenance run goes on to the next worker only once this one is back
34
+ * and ready: a reboot that failed, or one after which the worker is not
35
+ * ready, stops the rest.
36
+ */
37
+ export function rebootStopsRollout(node: { checks: Check[] }): boolean {
38
+ const reboot = node.checks.find((check) => check.id === 'worker:reboot');
39
+ if (!reboot) return false;
40
+ if (reboot.summary === 'WORKER_REBOOT_FAILED') return true;
41
+ return reboot.status === 'pass' && node.checks.some(blocking);
42
+ }
43
+
44
+ /** Installed updates wait for a reboot: what --maintenance-reboot applies. */
45
+ export function updatesPending(checks: Check[]): boolean {
46
+ return checks.some(
47
+ (check) => check.id === 'worker:reboot-pending' && check.status !== 'pass'
48
+ );
49
+ }
50
+
51
+ /** The home of the account a roster entry converges, from its workspace. */
52
+ export function workerHome(node: FleetNode): string | null {
53
+ const suffix = '/.local/share/personal/fleet';
54
+ return node.workspace.endsWith(suffix)
55
+ ? node.workspace.slice(0, -suffix.length) || null
56
+ : null;
57
+ }
58
+
59
+ /** Reboots one worker through ansible/ubuntu/reboot.yml, run from the
60
+ * manager: only while its T3 runs no agent turn (unless `force`), waiting
61
+ * until it answers again. The play's output is read for its own codes only.
62
+ */
63
+ export async function rebootWorker(
64
+ node: FleetNode,
65
+ request: { revision: string; reason: RebootReason; force?: boolean },
66
+ playbook: PackagePlaybook
67
+ ): Promise<Check> {
68
+ const home = workerHome(node);
69
+ if (!home)
70
+ return {
71
+ id: 'worker:reboot',
72
+ status: 'fail',
73
+ summary: 'WORKER_REBOOT_FAILED',
74
+ remedy: {
75
+ action:
76
+ 'The roster names no workspace under the account home, so the worker was not rebooted. Rerun fleet-sync once it is enrolled.',
77
+ },
78
+ };
79
+ // `user@alias` only for a worker whose account this run created.
80
+ // A worker reached from declared data logs in as its declared account.
81
+ const connection = playbookConnection(node);
82
+ const [login, target] = node.ssh.includes('@')
83
+ ? node.ssh.split('@', 2)
84
+ : [connection.user ?? '', node.ssh];
85
+ const result = await playbook(
86
+ 'ubuntu/reboot.yml',
87
+ {
88
+ target,
89
+ login,
90
+ home,
91
+ revision: request.revision,
92
+ reason: request.reason,
93
+ force: Boolean(request.force),
94
+ ssh_options: connection.options,
95
+ },
96
+ // The play waits up to 900 seconds for the worker to answer again.
97
+ 1_200_000
98
+ );
99
+ if (result.ok)
100
+ return {
101
+ id: 'worker:reboot',
102
+ status: 'pass',
103
+ summary:
104
+ request.reason === 'first-converge'
105
+ ? 'Rebooted once after its first converge, which no agent turn ran on; it answered again and was converged once more.'
106
+ : 'Rebooted for maintenance while no agent turn ran; it answered again and was converged once more.',
107
+ };
108
+ if (result.stdout.includes('WORKER_REBOOT_BUSY'))
109
+ return {
110
+ id: 'worker:reboot',
111
+ // A busy worker is left alone, and the others still go: its boot
112
+ // policy or its pending updates still say a reboot is needed.
113
+ status: 'warn',
114
+ summary: 'WORKER_REBOOT_BUSY',
115
+ remedy: {
116
+ action:
117
+ 'An agent turn is running on the worker, so it was not rebooted. Rerun fleet-sync --maintenance-reboot once the turn has ended, or add --force to end it.',
118
+ },
119
+ };
120
+ return {
121
+ id: 'worker:reboot',
122
+ status: 'fail',
123
+ summary: 'WORKER_REBOOT_FAILED',
124
+ remedy: {
125
+ action:
126
+ 'The reboot did not complete, or the worker did not answer within 15 minutes. Check its console with the provider, then rerun fleet-sync.',
127
+ },
128
+ };
129
+ }
@@ -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
+ }
@@ -0,0 +1,143 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { parse, stringify } from 'yaml';
5
+ import type { MachineDefinition } from './definition';
6
+ import { FleetUsageError } from './errors';
7
+ import { PACKAGE_ROOT } from './package-info';
8
+ import type { Runner } from './types';
9
+
10
+ /** What a new worker's first boot needs, and nothing that outlives it: the
11
+ * worker account with the manager's public key, the privilege rule
12
+ * fleet-sync expects, a host key generated here so the manager pins it before
13
+ * the machine ever answers (no trust on first use), and a short-lived,
14
+ * single-use Tailscale auth key that is deleted once the machine has joined.
15
+ */
16
+ export interface SeedInput {
17
+ worker: MachineDefinition;
18
+ /** The manager's SSH public key: one line of a `.pub` file. */
19
+ managerKey: string;
20
+ /** From the secrets provider, by purpose (`tailnet-auth-key`). */
21
+ authKey: string;
22
+ /** Generated with ssh-keygen for this seed. */
23
+ hostKey: { private: string; public: string };
24
+ /** `tailscale` in ansible/ubuntu/policy.json: version and key digest. */
25
+ tailscale: { version: string; keySha256: string };
26
+ /** The codename of the fleet's Ubuntu release, from the Ubuntu policy. */
27
+ codename: string;
28
+ /** A self-hosted control server the machine joins; the hosted Tailscale's
29
+ * when absent.
30
+ */
31
+ loginServer?: string;
32
+ }
33
+
34
+ /** The login server the Tailscale client uses when it is told none. */
35
+ const TAILSCALE_LOGIN_SERVER = 'https://controlplane.tailscale.com';
36
+
37
+ const PUBLIC_KEY =
38
+ /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}( [^\n]*)?$/;
39
+
40
+ /** The user data of one worker: bootstrap/worker-seed.yaml with every
41
+ * `${NAME}` filled. Values replace whole placeholders inside parsed strings,
42
+ * so none of them can change the document's structure.
43
+ */
44
+ export async function seedUserData(
45
+ input: SeedInput,
46
+ root = PACKAGE_ROOT
47
+ ): Promise<Record<string, unknown>> {
48
+ const { worker } = input;
49
+ const user = worker.network?.user;
50
+ if (!user)
51
+ throw new FleetUsageError(
52
+ `${worker.id} declares no network block; the seed needs its user and Tailscale name.`
53
+ );
54
+ if (!PUBLIC_KEY.test(input.managerKey.trim()))
55
+ throw new FleetUsageError(
56
+ 'The manager public key is not one OpenSSH public key line.'
57
+ );
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
+ )
66
+ throw new FleetUsageError(
67
+ 'The tailnet-auth-key secret is not a Tailscale auth key (tskey-auth-…). Use a short-lived, single-use, tagged key.'
68
+ );
69
+ const values: Record<string, string> = {
70
+ HOSTNAME: worker.network!.tailscaleName.split('.')[0]!,
71
+ USER: user,
72
+ MANAGER_KEY: input.managerKey.trim(),
73
+ HOST_KEY_PRIVATE: input.hostKey.private,
74
+ HOST_KEY_PUBLIC: input.hostKey.public,
75
+ AUTH_KEY: input.authKey,
76
+ TAILSCALE_VERSION: input.tailscale.version,
77
+ TAILSCALE_KEY_SHA256: input.tailscale.keySha256,
78
+ CODENAME: input.codename,
79
+ LOGIN_SERVER: input.loginServer ?? TAILSCALE_LOGIN_SERVER,
80
+ };
81
+ const template = parse(
82
+ await readFile(join(root, 'bootstrap/worker-seed.yaml'), 'utf8')
83
+ ) as unknown;
84
+ return fill(template, values) as Record<string, unknown>;
85
+ }
86
+
87
+ function fill(value: unknown, values: Record<string, string>): unknown {
88
+ if (typeof value === 'string')
89
+ return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name: string) => {
90
+ const found = values[name];
91
+ if (found === undefined)
92
+ throw new Error(`The seed template names an unknown value ${name}.`);
93
+ return found;
94
+ });
95
+ if (Array.isArray(value)) return value.map((item) => fill(item, values));
96
+ if (value && typeof value === 'object')
97
+ return Object.fromEntries(
98
+ Object.entries(value).map(([key, item]) => [key, fill(item, values)])
99
+ );
100
+ return value;
101
+ }
102
+
103
+ /** Cloud-init user data, or an Ubuntu autoinstall file that carries it. */
104
+ export function renderSeed(
105
+ userData: Record<string, unknown>,
106
+ autoinstall: boolean
107
+ ): string {
108
+ const document = autoinstall
109
+ ? {
110
+ autoinstall: {
111
+ version: 1,
112
+ ssh: { 'install-server': true, 'allow-pw': false },
113
+ 'user-data': userData,
114
+ },
115
+ }
116
+ : userData;
117
+ return `#cloud-config\n${stringify(document, { lineWidth: 0 })}`;
118
+ }
119
+
120
+ /** A new ed25519 host key, generated in a private directory that is removed
121
+ * afterwards.
122
+ */
123
+ export async function generateHostKey(
124
+ id: string,
125
+ run: Runner
126
+ ): Promise<SeedInput['hostKey']> {
127
+ const directory = await mkdtemp(join(tmpdir(), 'devfleet-seed-'));
128
+ try {
129
+ const path = join(directory, 'host');
130
+ const made = await run(
131
+ ['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', id, '-f', path],
132
+ { timeoutMs: 20_000 }
133
+ );
134
+ if (!made.ok)
135
+ throw new FleetUsageError('ssh-keygen could not make a host key.');
136
+ return {
137
+ private: await readFile(path, 'utf8'),
138
+ public: (await readFile(`${path}.pub`, 'utf8')).trim(),
139
+ };
140
+ } finally {
141
+ await rm(directory, { recursive: true, force: true });
142
+ }
143
+ }
@@ -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
- }