@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,185 @@
1
+ import type { FleetDefinition, MachineDefinition } from './definition';
2
+ import type { SecretsProvider } from './secrets';
3
+ import {
4
+ TailnetControlError,
5
+ controlRemedy,
6
+ fleetTailnetControl,
7
+ type ControlHttp,
8
+ type TailnetConfig,
9
+ type TailnetControl,
10
+ } from './tailnet-control';
11
+ import type { Check } from './types';
12
+
13
+ /** Removes the tailnet device a replaced or retired worker ran as, so the
14
+ * next machine under its id gets the declared name. Asked through the
15
+ * control plane fleet.json declares (`tailnet`), with the `tailnet-admin`
16
+ * credential; the credential is read when a device is removed, never kept.
17
+ */
18
+ export interface TailnetCleanup {
19
+ /** Removes the device `deviceId` that `worker` ran as; a check
20
+ * `tailscale:device` that says whether it is gone.
21
+ */
22
+ removeDevice(worker: MachineDefinition, deviceId: string): Promise<Check>;
23
+ }
24
+
25
+ /** The device left to a person: no control plane is declared, or no
26
+ * credential to ask it with is stored.
27
+ */
28
+ export function deviceLeftToPerson(
29
+ worker: MachineDefinition,
30
+ device: string,
31
+ why = 'no tailnet control is configured'
32
+ ): Check {
33
+ return {
34
+ id: 'tailscale:device',
35
+ status: 'skip',
36
+ summary: `The tailnet device ${device} was not removed: ${why}.`,
37
+ remedy: {
38
+ action: `Remove the device ${device} in the tailnet's admin console, so that a new machine gets the name ${worker.network?.tailscaleName ?? worker.id}. To have it removed by itself, declare tailnet in fleet.json and store the secret tailnet-admin.`,
39
+ },
40
+ };
41
+ }
42
+
43
+ /** The cleanup through the tailnet control plane a fleet declares. */
44
+ export function tailnetCleanup(
45
+ config: TailnetConfig,
46
+ secret: SecretsProvider,
47
+ http?: ControlHttp
48
+ ): TailnetCleanup {
49
+ return {
50
+ async removeDevice(worker, device) {
51
+ let control: TailnetControl | null;
52
+ try {
53
+ control = await fleetTailnetControl(config, secret, http);
54
+ } catch (error) {
55
+ if (
56
+ error instanceof TailnetControlError &&
57
+ error.code === 'TAILNET_ADMIN_REQUIRED'
58
+ )
59
+ return deviceLeftToPerson(
60
+ worker,
61
+ device,
62
+ 'the secrets provider holds no tailnet-admin'
63
+ );
64
+ throw error;
65
+ }
66
+ if (!control) return deviceLeftToPerson(worker, device);
67
+ try {
68
+ await control.deleteDevice(device);
69
+ } catch (error) {
70
+ if (!(error instanceof TailnetControlError)) throw error;
71
+ // A device that is already gone is what was asked for.
72
+ if (!(await stillListed(control, config.tag, device)))
73
+ return removed(device);
74
+ return {
75
+ id: 'tailscale:device',
76
+ status: 'warn',
77
+ summary: error.code,
78
+ remedy: {
79
+ action: `${controlRemedy(error.code)} Or remove the device ${device} in the tailnet's admin console.`,
80
+ },
81
+ };
82
+ }
83
+ return removed(device);
84
+ },
85
+ };
86
+ }
87
+
88
+ const removed = (device: string): Check => ({
89
+ id: 'tailscale:device',
90
+ status: 'pass',
91
+ summary: `The tailnet no longer holds the device ${device}.`,
92
+ });
93
+
94
+ async function stillListed(
95
+ control: TailnetControl,
96
+ tag: string,
97
+ device: string
98
+ ): Promise<boolean> {
99
+ try {
100
+ return (await control.listDevices(tag)).some(
101
+ (entry) => entry.id === device
102
+ );
103
+ } catch {
104
+ return true;
105
+ }
106
+ }
107
+
108
+ /** A device name as the control plane gives it, for a report: one DNS-like
109
+ * name, or nothing. Never anything else the control plane answered.
110
+ */
111
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
112
+ const label = (name: string): string =>
113
+ name.toLowerCase().replace(/\.$/, '').split('.')[0]!;
114
+
115
+ /** Devices that carry the fleet's worker tag and are no declared worker
116
+ * (parked ones included) nor a device the roster binds: a machine left over
117
+ * from a rebuild, or one joined by hand. Each is a warning `tailscale:leftover`
118
+ * that names the device by its name only. A fleet without a control plane,
119
+ * or without the credential to ask it, says nothing here.
120
+ */
121
+ export async function tailnetLeftovers(
122
+ definition: FleetDefinition,
123
+ control: () => Promise<TailnetControl | null>,
124
+ bound: string[] = []
125
+ ): Promise<Check[]> {
126
+ const tag = definition.tailnet?.tag;
127
+ if (!tag) return [];
128
+ let devices;
129
+ try {
130
+ const found = await control();
131
+ if (!found) return [];
132
+ devices = await found.listDevices(tag);
133
+ } catch (error) {
134
+ if (!(error instanceof TailnetControlError)) throw error;
135
+ if (error.code === 'TAILNET_ADMIN_REQUIRED') return [];
136
+ return [
137
+ {
138
+ id: 'tailscale:leftover',
139
+ status: 'skip',
140
+ summary: error.code,
141
+ remedy: { action: controlRemedy(error.code) },
142
+ },
143
+ ];
144
+ }
145
+ const declared = new Set(
146
+ [definition.manager, ...definition.workers].flatMap((machine) => [
147
+ machine.id,
148
+ ...(machine.network ? [label(machine.network.tailscaleName)] : []),
149
+ ])
150
+ );
151
+ return devices
152
+ .filter(
153
+ (device) =>
154
+ !bound.includes(device.id) && !declared.has(label(device.name))
155
+ )
156
+ .map((device) => {
157
+ const name = NAME.test(device.name) ? device.name : 'without a name';
158
+ return {
159
+ id: 'tailscale:leftover',
160
+ status: 'warn' as const,
161
+ summary: `The tailnet holds the device ${name}, tagged ${tag}, which is no declared worker.`,
162
+ remedy: {
163
+ action: `If the device ${name} left the fleet, remove it in the tailnet's admin console. If it is a worker, declare it in fleet.json (or its worker record).`,
164
+ },
165
+ };
166
+ });
167
+ }
168
+
169
+ /** Adds the leftover devices to a fleet-status report, as their own
170
+ * `tailnet` entry; nothing when there are none.
171
+ */
172
+ export async function appendTailnetLeftovers(
173
+ reports: { node: string; checks: Check[] }[],
174
+ definition: FleetDefinition,
175
+ control: (() => Promise<TailnetControl | null>) | undefined,
176
+ nodes: { tailscaleId?: string }[]
177
+ ): Promise<void> {
178
+ if (!control) return;
179
+ const checks = await tailnetLeftovers(
180
+ definition,
181
+ control,
182
+ nodes.flatMap((node) => (node.tailscaleId ? [node.tailscaleId] : []))
183
+ );
184
+ if (checks.length) reports.push({ node: 'tailnet', checks });
185
+ }
@@ -0,0 +1,346 @@
1
+ import { record } from './config';
2
+ import type { SecretsProvider } from './secrets';
3
+
4
+ /** The tailnet control plane a fleet enrols its workers with: the hosted
5
+ * Tailscale (API v2) or a self-hosted Headscale (its REST API). The contract
6
+ * is "a control plane that can issue pre-authorised keys": for each new
7
+ * worker dev-fleet asks it for one key that is single-use, pre-authorised,
8
+ * tagged and expires within minutes. The credential that asks is the secret
9
+ * `tailnet-admin`; it stays on the manager and never reaches a worker.
10
+ */
11
+ export type TailnetConfig =
12
+ | {
13
+ api: 'tailscale';
14
+ /** The API origin; https://api.tailscale.com when not named. */
15
+ url?: string;
16
+ tag: string;
17
+ }
18
+ | {
19
+ api: 'headscale';
20
+ /** The Headscale server: its API, and the login server workers use. */
21
+ url: string;
22
+ tag: string;
23
+ /** The Headscale user the keys belong to (its ID from Headscale 0.26). */
24
+ user: string;
25
+ };
26
+
27
+ const ORIGIN = /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/;
28
+ const TAG = /^tag:[a-z0-9][a-z0-9-]{0,62}$/;
29
+ const USER = /^[A-Za-z0-9._@-]{1,64}$/;
30
+ const TAILSCALE_API = 'https://api.tailscale.com';
31
+
32
+ /** The `tailnet` block of a fleet configuration, or null when it is invalid. */
33
+ export function parseTailnetConfig(value: unknown): TailnetConfig | null {
34
+ if (!record(value)) return null;
35
+ const { api, url, tag, user } = value;
36
+ const allowed =
37
+ api === 'headscale' ? ['api', 'url', 'tag', 'user'] : ['api', 'url', 'tag'];
38
+ if (Object.keys(value).some((key) => !allowed.includes(key))) return null;
39
+ if (!matches(tag, TAG) || (url !== undefined && !matches(url, ORIGIN)))
40
+ return null;
41
+ if (api === 'tailscale')
42
+ return { api, tag, ...(typeof url === 'string' ? { url } : {}) };
43
+ return api === 'headscale' && typeof url === 'string' && matches(user, USER)
44
+ ? { api, url, tag, user }
45
+ : null;
46
+ }
47
+
48
+ function matches(value: unknown, pattern: RegExp): value is string {
49
+ return typeof value === 'string' && pattern.test(value);
50
+ }
51
+
52
+ /** The server a worker logs in to; null for the hosted Tailscale, whose
53
+ * client already knows it.
54
+ */
55
+ export function loginServer(config: TailnetConfig | undefined): string | null {
56
+ return config?.api === 'headscale' ? config.url : null;
57
+ }
58
+
59
+ /** One request to the control plane, and its answer. Injected in scenarios. */
60
+ export interface ControlRequest {
61
+ method: 'GET' | 'POST' | 'DELETE';
62
+ url: string;
63
+ headers: Record<string, string>;
64
+ body?: string;
65
+ }
66
+ export interface ControlResponse {
67
+ status: number;
68
+ /** The parsed JSON body; null when there is none. */
69
+ body: unknown;
70
+ }
71
+ export type ControlHttp = (request: ControlRequest) => Promise<ControlResponse>;
72
+
73
+ /** Why the control plane did not do what was asked. Never carries what it
74
+ * answered: a body may echo the credential or a key.
75
+ */
76
+ export class TailnetControlError extends Error {
77
+ constructor(
78
+ readonly code:
79
+ | 'TAILNET_ADMIN_REQUIRED'
80
+ | 'TAILNET_CONTROL_DENIED'
81
+ | 'TAILNET_CONTROL_REJECTED'
82
+ | 'TAILNET_CONTROL_UNAVAILABLE'
83
+ | 'TAILNET_CONTROL_RESPONSE_INVALID'
84
+ ) {
85
+ super(code);
86
+ }
87
+ }
88
+
89
+ export interface AuthKey {
90
+ /** Delivered to one worker only: in its seed, or on the bootstrap's stdin. */
91
+ key: string;
92
+ expires: string;
93
+ }
94
+ export interface TailnetDevice {
95
+ /** The stable node ID: what `tailscale status --json` calls ID. */
96
+ id: string;
97
+ name: string;
98
+ tags: string[];
99
+ }
100
+ export interface TailnetControl {
101
+ /** One key: single-use, pre-authorised, carrying the fleet's tag and
102
+ * expiring after `expirySeconds` (ten minutes when not named).
103
+ */
104
+ createAuthKey(request: {
105
+ worker: string;
106
+ expirySeconds?: number;
107
+ }): Promise<AuthKey>;
108
+ /** Every device that carries the tag. */
109
+ listDevices(tag: string): Promise<TailnetDevice[]>;
110
+ deleteDevice(id: string): Promise<void>;
111
+ /** A worker's node key never expires, so nobody renews it. Tagged devices
112
+ * already have key expiry disabled on the hosted Tailscale; this disables
113
+ * it for a device that has it. Headscale gives a node registered with a
114
+ * pre-authorised key no expiry, so there it changes nothing.
115
+ */
116
+ disableKeyExpiry(id: string): Promise<void>;
117
+ }
118
+
119
+ const KEY = /^[A-Za-z0-9_-]{16,256}$/;
120
+ const ID = /^[A-Za-z0-9_-]{1,128}$/;
121
+ const DEFAULT_EXPIRY_SECONDS = 600;
122
+
123
+ /** The control plane `config` names, asked with `credential`. */
124
+ export function tailnetControl(
125
+ config: TailnetConfig,
126
+ credential: string,
127
+ http: ControlHttp = fetchHttp
128
+ ): TailnetControl {
129
+ const origin = config.url ?? TAILSCALE_API;
130
+ const ask = async (
131
+ method: ControlRequest['method'],
132
+ path: string,
133
+ body?: object
134
+ ): Promise<unknown> => {
135
+ let response: ControlResponse;
136
+ try {
137
+ response = await http({
138
+ method,
139
+ url: `${origin}${path}`,
140
+ headers: {
141
+ authorization: `Bearer ${credential}`,
142
+ accept: 'application/json',
143
+ ...(body ? { 'content-type': 'application/json' } : {}),
144
+ },
145
+ ...(body ? { body: JSON.stringify(body) } : {}),
146
+ });
147
+ } catch {
148
+ throw new TailnetControlError('TAILNET_CONTROL_UNAVAILABLE');
149
+ }
150
+ if (response.status === 401 || response.status === 403)
151
+ throw new TailnetControlError('TAILNET_CONTROL_DENIED');
152
+ if (response.status >= 500 || response.status === 429)
153
+ throw new TailnetControlError('TAILNET_CONTROL_UNAVAILABLE');
154
+ if (response.status < 200 || response.status > 299)
155
+ throw new TailnetControlError('TAILNET_CONTROL_REJECTED');
156
+ return response.body;
157
+ };
158
+ const device = (id: string) => {
159
+ if (!ID.test(id)) throw new TailnetControlError('TAILNET_CONTROL_REJECTED');
160
+ return encodeURIComponent(id);
161
+ };
162
+ return config.api === 'tailscale'
163
+ ? tailscaleApi(config, ask, device)
164
+ : headscaleApi(config, ask, device);
165
+ }
166
+
167
+ type Ask = (
168
+ method: ControlRequest['method'],
169
+ path: string,
170
+ body?: object
171
+ ) => Promise<unknown>;
172
+
173
+ function seconds(request: { expirySeconds?: number }): number {
174
+ const value = request.expirySeconds ?? DEFAULT_EXPIRY_SECONDS;
175
+ // Minutes, never hours: a key that leaks is useless soon after.
176
+ return Math.min(Math.max(Math.floor(value), 60), 3600);
177
+ }
178
+
179
+ function authKey(key: unknown, expires: unknown): AuthKey {
180
+ if (typeof key !== 'string' || !KEY.test(key) || typeof expires !== 'string')
181
+ throw new TailnetControlError('TAILNET_CONTROL_RESPONSE_INVALID');
182
+ return { key, expires };
183
+ }
184
+
185
+ function strings(value: unknown): string[] {
186
+ return Array.isArray(value)
187
+ ? value.filter((item): item is string => typeof item === 'string')
188
+ : [];
189
+ }
190
+
191
+ /** Tailscale API v2: https://tailscale.com/api. `-` is the tailnet of the
192
+ * credential.
193
+ */
194
+ function tailscaleApi(
195
+ config: TailnetConfig,
196
+ ask: Ask,
197
+ device: (id: string) => string
198
+ ): TailnetControl {
199
+ return {
200
+ async createAuthKey(request) {
201
+ const answer = await ask('POST', '/api/v2/tailnet/-/keys', {
202
+ capabilities: {
203
+ devices: {
204
+ create: {
205
+ reusable: false,
206
+ ephemeral: false,
207
+ preauthorized: true,
208
+ tags: [config.tag],
209
+ },
210
+ },
211
+ },
212
+ expirySeconds: seconds(request),
213
+ description: `devfleet ${request.worker}`.slice(0, 50),
214
+ });
215
+ if (!record(answer))
216
+ throw new TailnetControlError('TAILNET_CONTROL_RESPONSE_INVALID');
217
+ return authKey(answer['key'], answer['expires']);
218
+ },
219
+ async listDevices(tag) {
220
+ const answer = await ask('GET', '/api/v2/tailnet/-/devices');
221
+ if (!record(answer) || !Array.isArray(answer['devices']))
222
+ throw new TailnetControlError('TAILNET_CONTROL_RESPONSE_INVALID');
223
+ return answer['devices'].filter(record).flatMap((entry) => {
224
+ const id = entry['nodeId'] ?? entry['id'];
225
+ const tags = strings(entry['tags']);
226
+ return typeof id === 'string' && tags.includes(tag)
227
+ ? [{ id, name: String(entry['hostname'] ?? entry['name']), tags }]
228
+ : [];
229
+ });
230
+ },
231
+ async deleteDevice(id) {
232
+ await ask('DELETE', `/api/v2/device/${device(id)}`);
233
+ },
234
+ async disableKeyExpiry(id) {
235
+ await ask('POST', `/api/v2/device/${device(id)}/key`, {
236
+ keyExpiryDisabled: true,
237
+ });
238
+ },
239
+ };
240
+ }
241
+
242
+ /** Headscale's REST API (/api/v1), asked with one of its API keys. */
243
+ function headscaleApi(
244
+ config: Extract<TailnetConfig, { api: 'headscale' }>,
245
+ ask: Ask,
246
+ device: (id: string) => string
247
+ ): TailnetControl {
248
+ return {
249
+ async createAuthKey(request) {
250
+ const expiration = new Date(
251
+ Date.now() + seconds(request) * 1000
252
+ ).toISOString();
253
+ const answer = await ask('POST', '/api/v1/preauthkey', {
254
+ user: config.user,
255
+ reusable: false,
256
+ ephemeral: false,
257
+ expiration,
258
+ aclTags: [config.tag],
259
+ });
260
+ const key = record(answer) ? answer['preAuthKey'] : null;
261
+ if (!record(key))
262
+ throw new TailnetControlError('TAILNET_CONTROL_RESPONSE_INVALID');
263
+ return authKey(key['key'], key['expiration'] ?? expiration);
264
+ },
265
+ async listDevices(tag) {
266
+ const answer = await ask('GET', '/api/v1/node');
267
+ if (!record(answer) || !Array.isArray(answer['nodes']))
268
+ throw new TailnetControlError('TAILNET_CONTROL_RESPONSE_INVALID');
269
+ return answer['nodes'].filter(record).flatMap((entry) => {
270
+ const id = entry['id'];
271
+ // Headscale has named a node's tags differently across releases.
272
+ const tags = [
273
+ ...new Set([
274
+ ...strings(entry['tags']),
275
+ ...strings(entry['forcedTags']),
276
+ ...strings(entry['validTags']),
277
+ ]),
278
+ ];
279
+ return (typeof id === 'string' || typeof id === 'number') &&
280
+ tags.includes(tag)
281
+ ? [
282
+ {
283
+ id: String(id),
284
+ name: String(entry['givenName'] ?? entry['name']),
285
+ tags,
286
+ },
287
+ ]
288
+ : [];
289
+ });
290
+ },
291
+ async deleteDevice(id) {
292
+ await ask('DELETE', `/api/v1/node/${device(id)}`);
293
+ },
294
+ async disableKeyExpiry(id) {
295
+ device(id);
296
+ },
297
+ };
298
+ }
299
+
300
+ /** The fleet's control plane, or null when it declares none. A declared one
301
+ * without its credential is refused: nothing is enrolled on a guess.
302
+ */
303
+ export async function fleetTailnetControl(
304
+ config: TailnetConfig | undefined,
305
+ secret: SecretsProvider,
306
+ http?: ControlHttp
307
+ ): Promise<TailnetControl | null> {
308
+ if (!config) return null;
309
+ const credential = await secret('tailnet-admin');
310
+ if (!credential) throw new TailnetControlError('TAILNET_ADMIN_REQUIRED');
311
+ return tailnetControl(config, credential, http);
312
+ }
313
+
314
+ /** What each code asks the operator to do. Never what the server answered. */
315
+ export function controlRemedy(code: TailnetControlError['code']): string {
316
+ return {
317
+ TAILNET_ADMIN_REQUIRED:
318
+ 'fleet.json declares a tailnet control plane, but the secrets provider holds no tailnet-admin. Store an API credential that can create auth keys there (a Tailscale API access token or OAuth client secret with the auth_keys and devices scopes, or a Headscale API key), then rerun.',
319
+ TAILNET_CONTROL_DENIED:
320
+ 'The control plane refused the tailnet-admin credential. Replace it with one that is valid and may create tagged auth keys for the fleet tag, then rerun.',
321
+ TAILNET_CONTROL_REJECTED:
322
+ 'The control plane refused the request. Check that the tag in fleet.json (tailnet.tag) exists and that the credential may use it, and for Headscale that tailnet.user names an existing user, then rerun.',
323
+ TAILNET_CONTROL_UNAVAILABLE:
324
+ 'The control plane could not be reached or is busy. Check tailnet.url and the network, then rerun.',
325
+ TAILNET_CONTROL_RESPONSE_INVALID:
326
+ 'The control plane answered with something that is not an auth key or a device list. Check that tailnet.api names the right kind of server, then rerun.',
327
+ }[code];
328
+ }
329
+
330
+ const fetchHttp: ControlHttp = async (request) => {
331
+ const response = await fetch(request.url, {
332
+ method: request.method,
333
+ headers: request.headers,
334
+ ...(request.body ? { body: request.body } : {}),
335
+ redirect: 'error',
336
+ signal: AbortSignal.timeout(15_000),
337
+ });
338
+ const text = await response.text();
339
+ let body: unknown = null;
340
+ try {
341
+ body = text ? (JSON.parse(text) as unknown) : null;
342
+ } catch {
343
+ body = null;
344
+ }
345
+ return { status: response.status, body };
346
+ };
package/src/tailscale.ts CHANGED
@@ -167,7 +167,7 @@ export function keyExpiryCheck(
167
167
  const remedy = {
168
168
  action: computer.self
169
169
  ? 'Renew the node key of the manager before it expires: tailscale up --force-reauth on the manager, or disable key expiry for it in the Tailscale admin console.'
170
- : 'Disable key expiry for the worker in the Tailscale admin console, or tag it (tagged nodes do not expire). After an expiry, re-join it as docs/operations.md describes (Tailscale key expiry).',
170
+ : 'Disable key expiry for the worker in the Tailscale admin console, or tag it (tagged nodes do not expire). With tailnet and the secret tailnet-admin declared, fleet-sync enrols workers tagged and disables expiry for an enrolled worker that still has one. After an expiry, re-join it as docs/operations.md describes (Tailscale key expiry).',
171
171
  };
172
172
  if (days < 0)
173
173
  return { id, status: 'fail', summary: 'TAILSCALE_KEY_EXPIRED', remedy };
@@ -40,12 +40,21 @@ export async function toolchainChecks(
40
40
  return [
41
41
  ...(!result.ok || !data['ok'] ? [fallback] : []),
42
42
  ...(checks as Check[]),
43
+ ...(worker?.containers === false ? [dockerCliNotApplicable] : []),
43
44
  ];
44
45
  } catch {
45
46
  return [fallback];
46
47
  }
47
48
  }
48
49
 
50
+ /** The `containers` feature is off: the base tools leave the Docker CLI out. */
51
+ const dockerCliNotApplicable: Check = {
52
+ id: 'tool:docker-cli',
53
+ status: 'not-applicable',
54
+ summary:
55
+ 'Not applicable: containers are off for this worker (features.containers), so the Docker CLI is not installed.',
56
+ };
57
+
49
58
  /** Checks of the doctor that are not a tool version, with their own words. */
50
59
  const OWN: Record<string, { pass: string; fail: string; action: string }> = {
51
60
  'global-mise-config': {
@@ -107,7 +116,8 @@ function doctorArguments(root: string, worker?: ToolSelection): string[] {
107
116
  'bun',
108
117
  join(root, 'bootstrap/doctor.ts'),
109
118
  '--json',
110
- '--docker',
119
+ // A worker without containers has no Docker engine to require.
120
+ ...(worker?.containers === false ? [] : ['--docker']),
111
121
  ...(worker ? ['--worker', ...selectionArguments(worker)] : []),
112
122
  ];
113
123
  }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type Status = 'pass' | 'warn' | 'fail' | 'skip';
1
+ export type Status = 'pass' | 'warn' | 'fail' | 'skip' | 'not-applicable';
2
2
  export type Capability =
3
3
  | 'github'
4
4
  | 'vault'
@@ -40,7 +40,9 @@ export interface Check {
40
40
 
41
41
  /** Only a required failure blocks readiness. Warnings (conditions outside the
42
42
  * fleet, such as a provider quota) and skips (not observable here) are shown
43
- * but never fail a run.
43
+ * but never fail a run. A not-applicable check (the summary says why: a
44
+ * virtual machine has no firmware power setting) never fails, not even under
45
+ * --strict.
44
46
  */
45
47
  export function blocking(check: Check): boolean {
46
48
  return check.status === 'fail' && check.required !== false;
@@ -0,0 +1,63 @@
1
+ import type { PackagePlaybook } from './platform/ansible';
2
+ import { playbookConnection } from './ssh';
3
+ import type { Check } from './types';
4
+
5
+ /** The codes ansible/ubuntu/worker-account.yml stops with. */
6
+ const CODES = [
7
+ 'WORKER_ACCOUNT_UNDECLARED',
8
+ 'WORKER_ACCOUNT_ROOT_REQUIRED',
9
+ 'SUDOERS_OWNERSHIP_CONFLICT',
10
+ ];
11
+
12
+ /** The one command that creates the account by hand, for the remedy. */
13
+ export function accountCommand(user: string): string {
14
+ return `useradd --create-home --shell /bin/bash --groups sudo ${user}`;
15
+ }
16
+
17
+ /** Creates the declared worker account over a root login, through
18
+ * ansible/ubuntu/worker-account.yml run from the manager: an image that lets
19
+ * only root in gets the account the fleet converges, with the key root was
20
+ * reached with and the fleet's sudo rule. `login` is `root` when the alias
21
+ * names another user that cannot log in yet, and empty when the alias logs
22
+ * in as root itself.
23
+ */
24
+ export async function createWorkerAccount(
25
+ target: { id: string; ssh: string; login: 'root' | '' },
26
+ user: string | undefined,
27
+ playbook: PackagePlaybook
28
+ ): Promise<Check> {
29
+ const failure = (summary: string, action: string): Check => ({
30
+ id: 'worker:account',
31
+ status: 'fail',
32
+ summary,
33
+ remedy: { action },
34
+ });
35
+ if (!user || user === 'root')
36
+ return failure(
37
+ 'WORKER_ACCOUNT_UNDECLARED',
38
+ 'The worker lets only root in, and fleet.json names no non-root account for it. Declare workers[].network.user; fleet-sync then creates that account and continues as it.'
39
+ );
40
+ const result = await playbook(
41
+ 'ubuntu/worker-account.yml',
42
+ {
43
+ target: target.ssh,
44
+ login: target.login,
45
+ user,
46
+ ssh_options: playbookConnection(target).options,
47
+ },
48
+ 300_000
49
+ );
50
+ if (result.ok)
51
+ return {
52
+ id: 'worker:account',
53
+ status: 'pass',
54
+ summary: `Created the declared account ${user} over the root login, with the key root was reached with and the fleet's sudo rule.`,
55
+ };
56
+ const code =
57
+ CODES.find((candidate) => result.stdout.includes(candidate)) ??
58
+ 'WORKER_ACCOUNT_CREATION_FAILED';
59
+ return failure(
60
+ code,
61
+ `The declared account ${user} could not be created over the root login. Create it on the worker as root (${accountCommand(user)}), authorise the manager's key for it and grant it passwordless sudo, then rerun fleet-sync.`
62
+ );
63
+ }