@crvouga/dev-fleet 0.1.0 → 0.3.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 (159) hide show
  1. package/README.md +265 -37
  2. package/agents/setup.md +42 -12
  3. package/ansible/macos/manager.yml +10 -7
  4. package/ansible/macos/policy.json +2 -10
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/README.md +86 -25
  7. package/ansible/monitoring/agent.yml +40 -2
  8. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  9. package/ansible/monitoring/lock.json +5 -0
  10. package/ansible/pyproject.toml +12 -0
  11. package/ansible/ubuntu/baseline.yml +1 -1
  12. package/ansible/ubuntu/disk-gc.py +336 -0
  13. package/ansible/ubuntu/policy.json +13 -3
  14. package/ansible/ubuntu/t3-desktop.yml +19 -0
  15. package/ansible/ubuntu/t3-health.sh +93 -22
  16. package/ansible/ubuntu/t3-idle.py +42 -0
  17. package/ansible/ubuntu/t3-policy.yml +4 -11
  18. package/ansible/ubuntu/tailscale.yml +8 -5
  19. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  20. package/ansible/ubuntu/tasks/resources.yml +61 -1
  21. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  22. package/ansible/ubuntu/wip-guard.py +121 -0
  23. package/ansible/uv.lock +190 -0
  24. package/bootstrap/bootstrap.sh +25 -18
  25. package/bootstrap/doctor.ts +46 -1
  26. package/bootstrap/fleet-manager-run.sh +42 -15
  27. package/bootstrap/fleet-remote-bootstrap.sh +85 -12
  28. package/bootstrap/global-config.ts +64 -6
  29. package/bootstrap/install-launcher.sh +3 -0
  30. package/bootstrap/native-t3.ts +22 -11
  31. package/bootstrap/npm.ts +10 -2
  32. package/bootstrap/release-version.ts +157 -0
  33. package/cliproxy/README.md +3 -3
  34. package/cliproxy/scripts/accounts.py +216 -0
  35. package/cliproxy/scripts/checkpoint.py +29 -1
  36. package/cliproxy/scripts/cli.py +1 -1
  37. package/cliproxy/scripts/clientkeys.py +99 -0
  38. package/cliproxy/scripts/lease.py +80 -0
  39. package/cliproxy/scripts/ops +137 -25
  40. package/cliproxy/scripts/restore-verify +15 -0
  41. package/docs/accounts.md +154 -0
  42. package/docs/consumer-setup.md +408 -0
  43. package/docs/declared.md +91 -0
  44. package/docs/durability.md +26 -0
  45. package/docs/machine-setup.md +400 -0
  46. package/docs/operations.md +276 -0
  47. package/docs/releasing.md +103 -0
  48. package/docs/state.md +254 -0
  49. package/docs/topology.md +69 -0
  50. package/docs/ubuntu-sync.md +357 -0
  51. package/docs/worker-contract.md +67 -0
  52. package/examples/fleet.json +5 -0
  53. package/llms.txt +36 -0
  54. package/mise.lock +29 -0
  55. package/mise.toml +15 -2
  56. package/package.json +11 -2
  57. package/schema/fleet.schema.json +137 -15
  58. package/src/accounts/checks.ts +220 -0
  59. package/src/accounts/context.ts +76 -0
  60. package/src/accounts/propagate.ts +62 -0
  61. package/src/accounts/roster.ts +85 -0
  62. package/src/accounts/selection.ts +67 -0
  63. package/src/accounts/sessions.ts +120 -0
  64. package/src/agents/account-relay.ts +176 -26
  65. package/src/agents/fleet-verify.ts +9 -0
  66. package/src/agents/launch.ts +84 -5
  67. package/src/agents/native-verify.ts +293 -22
  68. package/src/agents/provider-errors.ts +9 -2
  69. package/src/agents/registry.ts +7 -1
  70. package/src/cli/agent-guide.ts +39 -5
  71. package/src/cli/app.ts +11 -4
  72. package/src/cli/cli.ts +7 -0
  73. package/src/cli/command-index.ts +7 -0
  74. package/src/cli/fleet-accounts.ts +253 -0
  75. package/src/cli/fleet-state.ts +499 -0
  76. package/src/cli/fleet-sync-accounts.ts +58 -0
  77. package/src/cli/fleet-sync-checkout.ts +56 -17
  78. package/src/cli/fleet-sync-enrollment.ts +19 -23
  79. package/src/cli/fleet-sync-forward.ts +1 -1
  80. package/src/cli/fleet-sync-manager.ts +84 -12
  81. package/src/cli/fleet-sync-monitoring.ts +5 -5
  82. package/src/cli/fleet-sync-output.ts +7 -3
  83. package/src/cli/fleet-sync-remote.ts +31 -18
  84. package/src/cli/fleet-sync-state.ts +86 -0
  85. package/src/cli/fleet-sync.ts +159 -57
  86. package/src/cli/fleet.ts +4 -2
  87. package/src/cli/managed-health.ts +4 -4
  88. package/src/cli/node-sync.ts +7 -0
  89. package/src/cli/program.ts +20 -4
  90. package/src/cli/project.ts +51 -0
  91. package/src/cli/register-fleet.ts +86 -1
  92. package/src/cli/registry.ts +41 -1
  93. package/src/cli/roster.ts +46 -30
  94. package/src/cli/settings.ts +15 -12
  95. package/src/cli/source-operations.ts +27 -3
  96. package/src/cli/tailnet.ts +2 -2
  97. package/src/definition.ts +116 -4
  98. package/src/diagnostics.ts +1 -1
  99. package/src/fleet-config.ts +105 -19
  100. package/src/ide/definition.ts +86 -0
  101. package/src/ide/ide.ts +43 -0
  102. package/src/ide/index.ts +5 -0
  103. package/src/ide/orchestrate.ts +50 -0
  104. package/src/ide/t3.ts +111 -0
  105. package/src/machine/source.ts +3 -3
  106. package/src/machine/state.ts +15 -27
  107. package/src/manager-sync.ts +9 -18
  108. package/src/monitoring/agent-evidence.ts +146 -0
  109. package/src/monitoring/apply.ts +15 -3
  110. package/src/monitoring/beszel.ts +11 -4
  111. package/src/monitoring/enroll.ts +2 -1
  112. package/src/monitoring/hub-access.ts +4 -3
  113. package/src/monitoring/hub-backup.ts +121 -0
  114. package/src/monitoring/hub-viewer.ts +8 -8
  115. package/src/monitoring/hub.ts +3 -3
  116. package/src/monitoring/manager-hub.ts +30 -14
  117. package/src/node-converge-linux.ts +30 -7
  118. package/src/package-info.ts +45 -0
  119. package/src/platform/ansible.ts +19 -15
  120. package/src/platform/darwin.ts +0 -13
  121. package/src/platform/fallback.ts +0 -8
  122. package/src/platform/linux.ts +0 -10
  123. package/src/platform/resource-verify.ts +8 -4
  124. package/src/platform/t3-policy.ts +33 -6
  125. package/src/platform/t3-service.ts +104 -0
  126. package/src/platform/types.ts +0 -4
  127. package/src/platform/ubuntu-audit.ts +5 -2
  128. package/src/platform/windows.ts +0 -14
  129. package/src/remote-bootstrap.ts +17 -19
  130. package/src/remote-entry.ts +15 -1
  131. package/src/repair-catalog.ts +1 -1
  132. package/src/runner.ts +1 -1
  133. package/src/service-auth.ts +15 -5
  134. package/src/state/bucket.ts +208 -0
  135. package/src/state/check.ts +74 -0
  136. package/src/state/connection.ts +92 -0
  137. package/src/state/enrollment.ts +45 -0
  138. package/src/state/legacy.ts +89 -0
  139. package/src/state/litestream.ts +143 -0
  140. package/src/state/operator.ts +20 -0
  141. package/src/state/paths.ts +40 -0
  142. package/src/state/roster.ts +62 -0
  143. package/src/state/store.ts +169 -0
  144. package/src/state/sync.ts +352 -0
  145. package/src/t3/accounts.ts +7 -1
  146. package/src/t3/models.ts +133 -0
  147. package/src/t3/pairing.ts +31 -0
  148. package/src/t3/profile.ts +203 -0
  149. package/src/t3/version.ts +119 -0
  150. package/src/t3-endpoint.ts +64 -8
  151. package/src/t3-linked-check.ts +24 -2
  152. package/src/toolchain-checks.ts +25 -0
  153. package/src/transport.ts +3 -2
  154. package/src/types.ts +4 -0
  155. package/src/worker-access-checks.ts +56 -3
  156. package/src/worker-host-checks.ts +41 -13
  157. package/src/workstation-checks.ts +1 -1
  158. package/src/index.ts +0 -6
  159. package/src/test-fixtures.ts +0 -36
@@ -0,0 +1,408 @@
1
+ # Set up a fleet from your own project
2
+
3
+ This is the whole procedure for connecting dev-fleet to your own machines,
4
+ written so a coding agent can do it in one pass from the project that will hold
5
+ the fleet configuration. It assumes nothing about your accounts, hosts or
6
+ domains: every value below in `UPPER_CASE` or under `example.com` is a
7
+ placeholder you replace.
8
+
9
+ If you are an agent, read the [rules](#rules-for-agents) first, then follow
10
+ the steps in order. Stop and ask the human only at the points marked **Ask**.
11
+
12
+ ## What you end up with
13
+
14
+ ```
15
+ your-fleet/ any Git repository you own (it may hold other things too)
16
+ package.json pins @crvouga/dev-fleet to one exact version
17
+ package-lock.json or bun.lock, pnpm-lock.yaml: commit it
18
+ fleet.json your machines, services, secrets locations, state bucket
19
+ .gitignore keeps credentials and node_modules out of Git
20
+ AGENTS.md tells future agents how to use dev-fleet here
21
+ ```
22
+
23
+ Nothing else is needed. You never clone dev-fleet, copy its files or write a
24
+ wrapper script around a checkout of it: the npm package is the tool, and the
25
+ version in `package.json` is the commit every machine runs.
26
+
27
+ | Concept | Meaning |
28
+ | ----------- | ------------------------------------------------------------------------------------------------------ |
29
+ | manager | The one macOS machine you run `devfleet fleet-sync` on. Hosts the CLI proxy and the monitoring hub. |
30
+ | worker | An Ubuntu 26.04 machine the manager converges over SSH. Stateless: rebuilt from Git and your secrets. |
31
+ | fleet state | One SQLite database (roster, approved revision, configuration last applied) replicated to your bucket. |
32
+ | bucket | Any S3-compatible bucket you own, or a directory. The source of truth for the fleet state. |
33
+ | revision | The dev-fleet commit the installed package was published from (`npx devfleet --version`). |
34
+
35
+ ## Rules for agents
36
+
37
+ - **Do not clone, vendor or patch dev-fleet.** Not `node_modules`, not a
38
+ checkout `fleet-sync` puts on a machine, not a file it manages there. The
39
+ next install or sync replaces them. A bug or a missing feature is an issue
40
+ at <https://github.com/crvouga/dev-fleet/issues>; tell the human what is
41
+ blocked and link it. Issues are public: no credentials, hostnames,
42
+ accounts, domains or `fleet.json` contents.
43
+ - **Never put a secret in an argument, in `fleet.json`, in a commit or in a
44
+ log.** Bucket credentials are read only from the environment; every other
45
+ secret from the provider named under `secrets`.
46
+ - **Do not bypass SSH host keys, forward SSH agents or copy login caches**
47
+ between machines. Host trust, sudo and sign-ins are the human's steps.
48
+ - **Previews are safe; applies are not.** `--dry-run`, `fleet-status`,
49
+ `fleet-state` (without flags) and `commands --json` change nothing.
50
+ `fleet-sync` without `--dry-run` changes every machine.
51
+ - **Use the command, never the code.** dev-fleet has no library API: do not
52
+ import anything from `node_modules/@crvouga/dev-fleet/`. The project's own
53
+ tools go in the project's `mise.toml`; the global mise configuration on a
54
+ fleet machine is dev-fleet's.
55
+ - **Use JSON.** Every diagnostic command takes `--json` and
56
+ `--non-interactive`. Read the check IDs and `remedy.action` from the
57
+ output; do not parse the human table.
58
+ - The machine-readable contract (commands, exit codes, safety rules, how to
59
+ report a bug) is `npx devfleet commands --json`. Read it before running
60
+ anything else.
61
+
62
+ ## 1. Check what the human must already have
63
+
64
+ These cannot be installed or decided by dev-fleet. Verify each one; for any
65
+ that fails, **Ask** the human to do it and wait.
66
+
67
+ On the manager (the Mac this project is checked out on):
68
+
69
+ | Requirement | Verify |
70
+ | -------------------------------------------------------- | --------------------------------------------------------------- |
71
+ | macOS 14 or newer | `sw_vers -productVersion` |
72
+ | Git | `git --version` |
73
+ | Node 22 or newer (for `npx`) | `node --version` |
74
+ | Docker Desktop running (the proxy and the hub run in it) | `docker info --format '{{.ServerVersion}}'` |
75
+ | Tailscale signed in | `tailscale status --json` (look for `"BackendState":"Running"`) |
76
+ | An SSH alias per worker that works without a prompt | `ssh -o BatchMode=yes WORKER_ALIAS true` for each worker |
77
+
78
+ On every worker:
79
+
80
+ | Requirement | Verify, from the manager |
81
+ | ------------------------------------ | ------------------------------------------------------------------ |
82
+ | Ubuntu 26.04, amd64 or arm64 | `ssh WORKER_ALIAS '. /etc/os-release; echo $VERSION_ID; uname -m'` |
83
+ | A non-root account that can use sudo | `ssh WORKER_ALIAS 'id -u; sudo -n true && echo sudo-ok'` |
84
+ | Joined to the same tailnet | the worker appears in `tailscale status` on the manager |
85
+
86
+ A worker needs nothing else beforehand: no Git, curl, Python, Node, Bun or
87
+ mise. `fleet-sync` installs them. If `sudo -n true` fails, that is fine for
88
+ the first, interactive run, which asks for the password once and installs a
89
+ sudo rule for the fleet; it is not fine for unattended runs.
90
+
91
+ The `ssh` value of a worker in `fleet.json` is an alias from the manager's
92
+ `~/.ssh/config`. dev-fleet always connects with `BatchMode=yes` and
93
+ `StrictHostKeyChecking=yes`, so the host key must already be in
94
+ `known_hosts`. **Ask** the human to verify each fingerprint out of band and
95
+ connect once by hand; never add `StrictHostKeyChecking=no` or
96
+ `accept-new`. A typical alias:
97
+
98
+ ```
99
+ Host worker-one
100
+ HostName worker-one.tail0a1b2c.ts.net
101
+ User dev
102
+ ```
103
+
104
+ Also **Ask** for these, which only the human has:
105
+
106
+ 1. **A bucket for the fleet state.** An empty private bucket on any
107
+ S3-compatible service (AWS S3, Cloudflare R2, Backblaze B2, MinIO) and an
108
+ access key that can read and write it: bucket name, endpoint (omit for AWS
109
+ S3), region (`auto` for R2). For a trial, an absolute directory path works
110
+ instead and needs no credentials.
111
+ 2. **Where secrets are kept**, one of `vault`, `sops`, `file` or `env` (see
112
+ [secrets](#secrets)). With `vault`, the address, the KV v2 mount and the
113
+ path and field of each secret.
114
+ 3. **The public origins** of the CLI proxy and of the monitoring dashboard,
115
+ and the two monitoring account emails (`services` below). They are
116
+ placeholders until the human has the domains.
117
+ 4. **The GitHub login** every worker signs in as, and the Vault user name.
118
+
119
+ ## 2. Create the project
120
+
121
+ ```sh
122
+ mkdir -p your-fleet && cd your-fleet # or an existing repository
123
+ git init # if it is not one yet
124
+ npm init -y # if there is no package.json yet
125
+ npm install --save-dev --save-exact @crvouga/dev-fleet
126
+ npx devfleet --version # 1.2.3 (commit 3f2a…): the revision every machine will run
127
+ ```
128
+
129
+ Pin the exact version (`--save-exact`); a range would let a reinstall move the
130
+ whole fleet. Any package manager works (`bun add -d --exact`,
131
+ `pnpm add -D --save-exact`). The CLI runs on Bun, which the package brings as
132
+ a dependency; if your package manager skips install scripts, allow Bun's or
133
+ install Bun yourself.
134
+
135
+ Add to `.gitignore`:
136
+
137
+ ```gitignore
138
+ node_modules/
139
+ .env
140
+ .env.*
141
+ .fleet.local.json
142
+ ```
143
+
144
+ ## 3. Write `fleet.json`
145
+
146
+ Start from the example the installed package prints, which points `$schema`
147
+ at the schema shipped in the package, so editors validate the file:
148
+
149
+ ```sh
150
+ npx devfleet fleet-state --example > fleet.json
151
+ ```
152
+
153
+ Then replace every placeholder. Each field, from
154
+ [`schema/fleet.schema.json`](../schema/fleet.schema.json):
155
+
156
+ | Field | Value |
157
+ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
158
+ | `version` | `3` |
159
+ | `source.repository` | `https://github.com/crvouga/dev-fleet.git`, or your fork if you publish one. Machines only accept code from it. |
160
+ | `state` | `{ "bucket", "endpoint"?, "region"?, "prefix"? }` or `{ "directory": "/absolute/path" }`. Never credentials. |
161
+ | `secrets` | See [secrets](#secrets). |
162
+ | `manager.id` | A short lowercase id, `^[a-z][a-z0-9-]{0,62}$`, e.g. `manager-mac`. |
163
+ | `manager.os` | `"macOS"` |
164
+ | `manager.network.tailscaleName` | The manager's MagicDNS name: `tailscale status --json` → `.Self.DNSName` without the trailing dot. |
165
+ | `manager.network.user` | The manager's login name (`id -un`). |
166
+ | `manager.neverSleepOnPower` | `true` (workers depend on the manager's services). |
167
+ | `workers[].id` | A short lowercase id. The **first** worker is the canary: a new revision must pass there before the others get it. |
168
+ | `workers[].ssh` | The worker's alias in the manager's `~/.ssh/config`. |
169
+ | `workers[].os` | `"linux"` |
170
+ | `workers[].network.tailscaleName` | The worker's MagicDNS name (from `tailscale status --json` → `.Peer[].DNSName` on the manager). |
171
+ | `workers[].network.sshPort` | `22` unless the worker's sshd listens elsewhere. |
172
+ | `workers[].network.user` | The worker account (`ssh WORKER_ALIAS id -un`). |
173
+ | `workers[].network.t3Port` | `3773` |
174
+ | `services.proxy.endpoint` | The public HTTPS origin of the CLI proxy, e.g. `https://cli-proxy.example.com`. |
175
+ | `services.monitoring.hubPort` | `8443` |
176
+ | `services.monitoring.publicUrl` | The public HTTPS origin of the monitoring dashboard. |
177
+ | `services.monitoring.managerAccount` | The email of the monitoring account the manager enrolls machines with. |
178
+ | `services.monitoring.viewerAccount` | The email of the read-only account visitors are signed in as. |
179
+ | `ubuntu` | `{ "release": "26.04", "architectures": ["amd64", "arm64"] }` (fixed). |
180
+ | `worker.requiredCapabilities` | Normally all of `github`, `vault`, `t3`, `toolchain`, `workstation`, `worker`. |
181
+ | `worker.githubLogin` | The GitHub login every worker must be signed in as. |
182
+ | `worker.vaultUser` | The Vault user each worker's token is issued for. |
183
+ | `worker.toolchain`, `agentProfile` | `"mise.lock"`, `"proxy"` (fixed). |
184
+ | `worker.unattendedSudo`, `persistentT3`, `neverSleep` | `true` (fixed policy). |
185
+
186
+ Use the same values in every place they repeat. Validate before going on:
187
+
188
+ ```sh
189
+ npx devfleet fleet-state --json # exits 2 with code "invalid_usage" when fleet.json is invalid
190
+ ```
191
+
192
+ The `state:definition` check names the file it read. A `fleet.json` without a
193
+ `manager` object is not taken for a fleet configuration at all, and the
194
+ command then reports that it found none.
195
+
196
+ ### Secrets
197
+
198
+ dev-fleet asks for four secrets by purpose: `proxy-client-key`,
199
+ `monitoring-account-password`, `monitoring-tunnel-token` and
200
+ `proxy-tunnel-token`. `secrets.provider` says where they are:
201
+
202
+ | Provider | `fleet.json` | Where the values are |
203
+ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
204
+ | `vault` | `{ "provider": "vault", "address": "https://vault.example.com", "mount": "secret", "locations": { NAME: { "path", "field" } } }` with all four names | A Vault KV v2 mount. Each worker gets its own renewable token. |
205
+ | `sops` | `{ "provider": "sops", "file": "secrets.enc.yaml" }` | One encrypted document with one top-level key per name. |
206
+ | `file` | `{ "provider": "file" }` (optional `"directory"`) | One private file per name, default `~/.config/dev-fleet/secrets/NAME`. |
207
+ | `env` | `{ "provider": "env" }` | `DEVFLEET_SECRET_PROXY_CLIENT_KEY` and so on (upper case, `_` for `-`). |
208
+
209
+ Only `vault` gives each worker its own login and renewal and gives the CLI
210
+ proxy its encrypted backup; with the other three those two features are not
211
+ available yet. You never write a secret value into the project; the human puts
212
+ them in the provider. **Ask** for that step.
213
+
214
+ ### Bucket credentials
215
+
216
+ Every command reads them from the environment, never from arguments:
217
+
218
+ ```sh
219
+ export DEVFLEET_BUCKET_ACCESS_KEY_ID=…
220
+ export DEVFLEET_BUCKET_SECRET_ACCESS_KEY=…
221
+ ```
222
+
223
+ Pass them however the human keeps secrets: a password manager's `run`
224
+ command, `vault kv get` into the environment, or a git-ignored `.env` loaded
225
+ with `set -a; . ./.env; set +a`. With neither variable set, the standard AWS
226
+ environment or profile is used. A `"directory"` state needs none.
227
+
228
+ Runs the manager starts by itself (a worker handing `fleet-sync` to the
229
+ manager) get no environment, so save the credentials on the manager once:
230
+
231
+ ```sh
232
+ npx devfleet fleet-state --init # takes the bucket from fleet.json, asks on the terminal if the variables are unset
233
+ ```
234
+
235
+ ## 4. Tell future agents how to use it
236
+
237
+ Put this in the project's `AGENTS.md` (or `CLAUDE.md`):
238
+
239
+ ```md
240
+ The fleet is managed with dev-fleet (`npx devfleet`, pinned in package.json).
241
+ Read `npx devfleet commands --json` before using it. Never edit dev-fleet
242
+ itself: not node_modules, not a clone, not what it installs on a machine. A
243
+ bug in dev-fleet or a feature it lacks is a GitHub issue at
244
+ https://github.com/crvouga/dev-fleet/issues; tell me what is blocked and link
245
+ it. Issues are public: no credentials, hostnames, accounts, domains or
246
+ fleet.json contents.
247
+ ```
248
+
249
+ Optionally add scripts, so nobody types the package name:
250
+
251
+ ```json
252
+ "scripts": {
253
+ "fleet": "devfleet",
254
+ "fleet:sync": "devfleet fleet-sync",
255
+ "fleet:status": "devfleet fleet-status --workers"
256
+ }
257
+ ```
258
+
259
+ Commit `package.json`, the lockfile, `fleet.json`, `.gitignore` and
260
+ `AGENTS.md`.
261
+
262
+ ## 5. Preview
263
+
264
+ Run on the manager, from the project, with the bucket credentials in the
265
+ environment:
266
+
267
+ ```sh
268
+ npx devfleet fleet-state --json
269
+ npx devfleet fleet-sync --dry-run --json
270
+ ```
271
+
272
+ `fleet-state` reports the `state:bucket` and `state:replica` checks. On a new
273
+ fleet the bucket is empty, which is a warning, not a failure: the first sync
274
+ seeds it.
275
+
276
+ Before the first sync, the dry run is expected to stop with
277
+ `FLEET_MANAGER_ENROLLMENT_REQUIRED`: no worker's SSH connection and tailnet
278
+ identity has been enrolled yet. Anything else is a problem to fix first (see
279
+ [failures](#when-something-fails)).
280
+
281
+ ## 6. The first sync is the human's
282
+
283
+ The first `fleet-sync` must run in a terminal the human is at. It:
284
+
285
+ - enrolls each declared worker it reaches over host-key-verified SSH, binding
286
+ it to its tailnet device;
287
+ - asks once for each worker's sudo password and installs a sudo rule for the
288
+ fleet account (the password is never stored);
289
+ - with `vault`, asks for the Vault password once and issues each worker its own
290
+ token.
291
+
292
+ An agent cannot answer those prompts, and `--json`, `--non-interactive` or a
293
+ `CI` variable turn them off. **Ask** the human to run:
294
+
295
+ ```sh
296
+ npx devfleet fleet-sync
297
+ ```
298
+
299
+ It converges the manager first, then the canary worker, then the rest. It
300
+ prints one table and the path of the full JSON report
301
+ (`~/.local/share/personal/fleet/manager/last-sync.json`, readable by the human
302
+ only). The first run on a bare worker takes several minutes: it installs the
303
+ operating system packages, mise, the pinned toolchain, T3 and the monitoring
304
+ agent.
305
+
306
+ Then the human signs in, once, to what only they can sign in to:
307
+
308
+ ```sh
309
+ npx devfleet cliproxy install # the CLI proxy on the manager, if the sync reported it missing
310
+ npx devfleet cliproxy add-account claude
311
+ npx devfleet cliproxy add-account codex
312
+ ```
313
+
314
+ and pairs the T3 desktop app on the manager with each worker as its check
315
+ remedy says (`t3:paired-access`).
316
+
317
+ ## 7. Verify
318
+
319
+ These are read-only and safe for an agent to run at any time:
320
+
321
+ ```sh
322
+ npx devfleet fleet-status --workers --json # every worker against the declared policy
323
+ npx devfleet fleet-status --json # the manager and every enrolled worker
324
+ npx devfleet fleet-state --json # the bucket holds what this machine has
325
+ npx devfleet fleet-sync --dry-run --json # what the next sync would do
326
+ ```
327
+
328
+ Exit code `0` is success, `1` a failed check, `2` invalid usage or
329
+ configuration. Each check is `{ id, status, summary, remedy? }` with status
330
+ `pass`, `warn`, `fail` or `skip`. Some checks stay failing until a person
331
+ acts (a sign-in, a pairing, a maintenance reboot); they say so in
332
+ `remedy.action` and do not stop other workers from converging.
333
+
334
+ Later syncs need no prompt and may be run by an agent when the human asks:
335
+ `npx devfleet fleet-sync --json`.
336
+
337
+ ## When something fails
338
+
339
+ Read `summary` and `remedy.action` of the failing check. The common ones:
340
+
341
+ | Code or check | Meaning and remedy |
342
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
343
+ | `FLEET_MANAGER_ENROLLMENT_REQUIRED` | No worker is enrolled yet. The human runs the first `fleet-sync` in a terminal. |
344
+ | `FLEET_NOT_MANAGER` | You are not on the machine `manager` names. Run there, or from a worker that can SSH to it. |
345
+ | `FLEET_REVISION_REQUIRED` | No revision to converge to. Run the installed package (`npx devfleet`), not a copy of its source. |
346
+ | `FLEET_SOURCE_UNAVAILABLE` | The revision could not be fetched from `source.repository`. Check the network and that the URL is right. |
347
+ | `FLEET_STATE_DIVERGED` | This machine and the bucket hold different fleet states. `fleet-state --json` explains; never pick one without the human. |
348
+ | `FLEET_MANAGER_LOCKED` | Another run holds the manager's lock. Wait for it; do not delete the lock while a run is alive. |
349
+ | `FLEET_PLAN_STALE` | The plan changed after it was approved. Preview again. |
350
+ | `ssh:host-key` | Host key unknown or changed. The human verifies the fingerprint out of band; never disable checking. |
351
+ | `ssh:auth`, `ssh:dns`, `ssh:network` | The alias does not work non-interactively. Fix `~/.ssh/config`, the key or Tailscale, then `ssh -o BatchMode=yes ALIAS true`. |
352
+ | `TAILSCALE_NODE_OFFLINE` | The worker is off or off the tailnet. It is listed and skipped; others continue. |
353
+ | `TAILSCALE_DEVICE_REPLACED` | The tailnet device behind a worker changed. Nothing is written to it until the human confirms the replacement. |
354
+ | `SUDO_AUTHORIZATION_REQUIRED` | The worker account cannot use sudo without a password. Run the first sync interactively. |
355
+ | `UBUNTU_BASELINE_MISMATCH` | The worker is not Ubuntu 26.04. |
356
+ | `ROOT_DEVELOPMENT_USER_REJECTED` | The worker's SSH user is root. Use a normal account with sudo. |
357
+ | `BOOTSTRAP_DISK_CAPACITY_REQUIRED` | Less than 2 GiB free (or too few inodes) where the fleet keeps its files on the worker. |
358
+ | `FLEET_REMOTE_LOCKED` | Another operation is running on that worker. Wait and run again. |
359
+ | `FLEET_PROMOTION_BLOCKED` | The canary (first worker) failed, so the new revision went no further. Fix the canary first. |
360
+ | `VAULT_LOGIN_REQUIRED` | The manager is not logged in to Vault. The human logs in; the next sync issues worker tokens. |
361
+ | `PROVIDER_UNATTENDED_CREDENTIAL_REQUIRED` | A coding agent has no credential a background service can use. The human signs in on the proxy. |
362
+ | `BESZEL_ACCOUNT_REQUIRED` | The monitoring hub has no account yet. Follow the remedy; other workers are not blocked. |
363
+
364
+ A failure the remedy does not explain, or one you believe is a defect, is an
365
+ issue at <https://github.com/crvouga/dev-fleet/issues>: include
366
+ `npx devfleet --version`, the command and flags, and the failing check IDs
367
+ and codes; leave out everything private.
368
+
369
+ ## Day to day
370
+
371
+ - **Add a worker.** Set up its SSH alias and host key and join it to the
372
+ tailnet (step 1), add it under `workers`, commit, then the human runs
373
+ `npx devfleet fleet-sync` once in a terminal to enroll it.
374
+ - **Remove a worker.** Delete its entry, commit, run `npx devfleet fleet-sync`.
375
+ - **Upgrade the fleet.** `npm install --save-dev --save-exact @crvouga/dev-fleet@latest`,
376
+ commit, `npx devfleet fleet-sync`. The canary proves the new revision first.
377
+ Install an older version and sync to roll back.
378
+ - **Replace the manager.** On the new Mac: clone the project, `npm ci`, put
379
+ the bucket credentials in the environment, `npx devfleet fleet-sync`. The
380
+ roster and the approved revision come back from the bucket. See
381
+ [state](state.md) for what the bucket does not hold yet.
382
+
383
+ ## Moving from a pinned commit and a wrapper script
384
+
385
+ Some fleets were set up before the package existed: a `fleet.revision` file
386
+ holding a commit, and a script that cloned dev-fleet and ran it from that
387
+ clone. To move such a project to the package:
388
+
389
+ 1. `npm install --save-dev --save-exact @crvouga/dev-fleet@VERSION`, choosing
390
+ the release whose commit (`npx devfleet --version`) is the one in
391
+ `fleet.revision` or newer.
392
+ 2. Add the `state` block to `fleet.json` if it has none, with the bucket the
393
+ manager already uses (`~/.config/dev-fleet/bucket.json` on the manager
394
+ names it).
395
+ 3. Replace every call of the wrapper with `npx devfleet …`, then delete the
396
+ wrapper script and `fleet.revision`.
397
+ 4. Run `npx devfleet fleet-sync --dry-run --json`, then `npx devfleet fleet-sync`.
398
+ 5. The CLI proxy on the manager may still run from the old clone: run
399
+ `npx devfleet cliproxy install` once before deleting that clone
400
+ ([state](state.md) explains why).
401
+
402
+ ## Further reading
403
+
404
+ - [README](../README.md): what dev-fleet assumes and its known limits.
405
+ - [Fleet state](state.md): the bucket, replication and recovery.
406
+ - [What is declared](declared.md): what is pinned on a machine and what is not.
407
+ - [Topology](topology.md): the manager and worker roles.
408
+ - [CLI proxy](../cliproxy/README.md) and [monitoring](../ansible/monitoring/README.md).
@@ -0,0 +1,91 @@
1
+ # What is declared, and where
2
+
3
+ Two machines given the same dev-fleet version and the same `fleet.json` should
4
+ end up the same. That holds only for what is written down in a tracked file,
5
+ pinned to an exact version or digest, applied by a tool that converges
6
+ (Ansible or mise) and measured afterwards. This page lists what is, and what
7
+ is not yet.
8
+
9
+ `features/rules/declared.feature` keeps the rules below true on every pull
10
+ request. Its tables of exceptions are the same as [not declared yet](#not-declared-yet), and
11
+ may only shrink.
12
+
13
+ ## Declared
14
+
15
+ | What | Declared in | Pinned by | Applied by | Measured by |
16
+ | --------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------- |
17
+ | The fleet: machines, services, accounts | Your `fleet.json` | The dev-fleet version in your `package.json`, which names one commit | `fleet-sync` | `fleet-status` |
18
+ | Tools: languages, CLIs, coding agents | `mise.toml` | `mise.lock`: version and checksum for each platform | `mise install --locked` | Tool and `agent:verification` checks |
19
+ | mise itself | `min_version` and `mise_*_sha256` in `mise.toml` | The digest of its release archive, held here | `bootstrap/bootstrap.sh` | Its version, at every toolchain setup |
20
+ | JavaScript dependencies | `package.json` | `bun.lock` | `bun install --frozen-lockfile` | The install fails on a difference |
21
+ | T3 Code, server and desktop app | `t3_*` under `[vars]` in `mise.toml` | SHA-256 of each artifact | `bootstrap/native-t3.ts`; `ansible/ubuntu/t3-desktop.yml` | T3 checks; `t3:version` on every machine |
22
+ | T3 policy on the manager (updates off) | `ansible/ubuntu/t3/policy.json`, shared with the workers | The commit | `ansible/macos/t3-policy.yml`, once, as an administrator | `t3:updates` |
23
+ | Worker work-in-progress guard | `ansible/ubuntu/wip-guard.py`, `ansible/ubuntu/tasks/recovery.yml` | The commit | `fleet-sync` | `features/ide/work-in-progress.feature` |
24
+ | Ubuntu packages | `packages` and `snapshot` in `ansible/ubuntu/policy.json` | Exact versions from a dated archive snapshot | `ansible/ubuntu/tasks/packages.yml` | `package:*` |
25
+ | Tailscale on workers | `tailscale` in `ansible/ubuntu/policy.json` | Exact version; digest of the signing key | `ansible/ubuntu/tailscale.yml` | `package:tailscale` |
26
+ | Worker policy: power, SSH, sudo, memory, disk | `ansible/ubuntu/*.yml`, values shared with checks in `policy.json` | The commit | `fleet-sync` | Host and resource checks |
27
+ | Monitoring: hub, tunnel, agents | `ansible/monitoring/*.yml`, `compose.yml`, `lock.json` | Image digests; SHA-256 of each agent archive | `fleet-sync` | `beszel:*` |
28
+ | The manager's private route to the hub | `ansible/monitoring/hub-route-darwin.yml` | The commit | `fleet-sync` | The route, read back |
29
+ | The manager's power settings | `pmset` in `ansible/macos/policy.json` | The commit | `ansible/macos/manager.yml`, once, as an administrator | `manager:power` |
30
+ | Ansible on the manager | `ansible/pyproject.toml` | `ansible/uv.lock`: one release, one Python, every dependency hashed | `uv run --locked --isolated` | A hash that differs stops the run |
31
+ | Ansible on a worker | `ansible-core` in `ansible/ubuntu/policy.json` | Exact version | `ansible/ubuntu/tasks/packages.yml` | `package:ansible-core` |
32
+ | The CLI proxy | `cliproxy/compose.yaml`, `image.lock.json`, `requirements.lock`, `launchd/`, `config.template.yaml` | Image digests; hashed Python dependencies | `devfleet cliproxy install`, and `fleet-sync` after it | The proxy's audit |
33
+ | Agent profiles and T3 providers | `agents/registry.json`, `agents/t3.json` | The commit | `fleet-sync` | Profile receipts; the plan after every apply |
34
+ | Docker Desktop on the manager | `docker_desktop_*` under `[vars]` in `mise.toml` | Version and build | You | `manager:docker` (warns) |
35
+ | CI | `.github/workflows/` | Each action at a commit; tools from `mise.lock` | GitHub | `features/rules/` |
36
+
37
+ A value lives in a policy file when something besides the playbook reads it,
38
+ so the check and the playbook cannot disagree. A value only a playbook uses
39
+ stays in the playbook: the playbook is its declaration.
40
+
41
+ ## Not declared yet
42
+
43
+ - **A schedule.** Nothing re-applies the fleet on a timer, so a machine drifts
44
+ until someone runs `fleet-sync`. `fleet-sync --schedule enable` writes a
45
+ launchd job from TypeScript and has never worked from the published package.
46
+ It should become a playbook.
47
+ - **The rest of a worker's packages.** Only the packages in `policy.json` are
48
+ exact. `systemd-oomd` and the T3 desktop libraries are installed at whatever
49
+ version the machine's own archive holds, and the machine's own updates move
50
+ everything else. Pinning them needs every worker on the snapshot with its
51
+ own updates off, so security fixes arrive only when the snapshot moves. That
52
+ is a policy choice, not a missing line.
53
+ - **What a worker needs before Ansible exists.** `fleet-remote-bootstrap.sh`
54
+ installs Git, curl, CA certificates, Python and `ansible-core` from the
55
+ machine's own archive (the snapshot is HTTPS, and a new machine has no CA
56
+ certificates yet). The baseline then pins each of them.
57
+ - **The manager's applications.** Docker Desktop, Tailscale and Apple's
58
+ Command Line Tools are installed by a person. Docker Desktop is measured;
59
+ the other two are only required.
60
+ - **The CLI proxy's lifecycle.** It is installed and started by its own Python
61
+ scripts from the declarative files above, not by a playbook.
62
+ - **Files TypeScript and shell still write.** The installed `fleet.json`, the
63
+ `current` and `devfleet` links, the `PATH` lines in shell profiles, the
64
+ global mise configuration, the T3 server archive, agent profiles and T3
65
+ settings. Each is derived
66
+ from a tracked file and rewritten on every run, but by code.
67
+ - **Logins.** Tailscale, Vault, GitHub, each provider account and T3 pairing
68
+ belong to a machine and a person. They are checked, never copied.
69
+ - **Windows.** `bootstrap/bootstrap.ps1` exists; Windows is not a fleet role
70
+ and nothing tests it.
71
+
72
+ ## Why not Nix
73
+
74
+ Nix would give the same three things this has: pinned inputs, verified
75
+ artifacts and a declarative description of the machine. Here they come from
76
+ `mise.lock`, the Ubuntu snapshot and Ansible. Adding Nix would put a second
77
+ package universe on every machine beside the one that is already running, and
78
+ moving to it entirely means NixOS workers and nix-darwin on the manager, which
79
+ replaces every unit and path that exists today. The one thing above that Nix
80
+ would settle and the current tools do not is the rest of a worker's packages.
81
+
82
+ ## Changing a pin
83
+
84
+ | To change | Edit | Then |
85
+ | ----------------------- | -------------------------------------------------------------------- | ------------------------------------------------ |
86
+ | A tool | Its version in `mise.toml` | Update `mise.lock` with `mise lock` |
87
+ | mise | `min_version` and the four `mise_*_sha256` in `mise.toml` | Copy the digests from the release's `SHASUMS256` |
88
+ | An Ubuntu package | `packages` in `ansible/ubuntu/policy.json`, and `snapshot` if needed | The VM test in `ansible/ubuntu/tests` |
89
+ | Ansible on the manager | `ansible/pyproject.toml` | `uv lock` in `ansible/` |
90
+ | Beszel | `ansible/monitoring/lock.json`, `compose.yml`, the agent unit | `bun test features/rules` |
91
+ | A manager power setting | `pmset` in `ansible/macos/policy.json` | Run `manager.yml` once on the manager |
@@ -0,0 +1,26 @@
1
+ # What survives the loss of a machine
2
+
3
+ This is the one place that says what dev-fleet keeps when a machine is lost.
4
+
5
+ | Lost | Kept | Lost with it |
6
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
7
+ | A worker | Everything `fleet-sync` rebuilds from Git and secrets. Work in agent worktrees (`~/.t3/worktrees`) up to the last run of the work-in-progress guard, every 15 minutes, in `refs/recovery/<node>/<branch>` of each worktree's remote. The T3 environment ID in the roster. | Up to 15 minutes of uncommitted work; T3 threads and their transcripts on that worker. |
8
+ | The manager | The fleet state, from the bucket ([state](state.md)). The T3 profile, provider instances and policy, from `fleet.json` and Git. | T3's own data on the manager: its linked environments and its threads. They are not in a backup set yet. |
9
+
10
+ ## Recovering work from a lost worker
11
+
12
+ The guard pushes the files of each worktree as they were (changed and new
13
+ files, not ignored ones) as one commit on top of its `HEAD`, without touching
14
+ the worktree, its index or its branches:
15
+
16
+ ```sh
17
+ git fetch origin 'refs/recovery/*:refs/recovery/*'
18
+ git log refs/recovery/<node>/<branch>
19
+ git switch -c recovered refs/recovery/<node>/<branch>
20
+ ```
21
+
22
+ A worktree whose work is already on a branch of its remote pushes nothing.
23
+ The guard is `ansible/ubuntu/wip-guard.py`, run by the user timer
24
+ `personal-worker-wip-guard.timer` that `ansible/ubuntu/tasks/recovery.yml`
25
+ installs. It pushes with the worker's own Git credentials, so a worktree
26
+ whose remote the worker cannot write to is reported and skipped.