@crvouga/dev-fleet 0.6.0 → 0.8.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 (56) hide show
  1. package/README.md +32 -13
  2. package/ansible/macos/manager-privilege.yml +37 -0
  3. package/ansible/macos/manager.yml +51 -6
  4. package/ansible/macos/policy.json +6 -1
  5. package/ansible/macos/worker-host-key.yml +33 -0
  6. package/ansible/monitoring/agent.yml +2 -0
  7. package/ansible/ubuntu/baseline.yml +6 -0
  8. package/ansible/ubuntu/recovery.yml +2 -0
  9. package/ansible/ubuntu/tailscale.yml +2 -0
  10. package/bootstrap/worker-seed.yaml +61 -0
  11. package/docs/authentication.md +2 -0
  12. package/docs/consumer-setup.md +34 -35
  13. package/docs/declared.md +24 -24
  14. package/docs/manager-recovery.md +78 -0
  15. package/docs/manager-setup.md +27 -0
  16. package/docs/operations.md +8 -0
  17. package/docs/runbooks.md +15 -1
  18. package/docs/state.md +38 -6
  19. package/docs/topology.md +19 -11
  20. package/docs/ubuntu-sync.md +21 -15
  21. package/package.json +2 -2
  22. package/schema/check-ids.json +36 -0
  23. package/schema/fleet.schema.json +22 -2
  24. package/src/check-owners.ts +164 -0
  25. package/src/cli/fleet-manifest.ts +102 -0
  26. package/src/cli/fleet-node-lifecycle.ts +455 -0
  27. package/src/cli/fleet-node-seed.ts +151 -0
  28. package/src/cli/fleet-restore.ts +205 -0
  29. package/src/cli/fleet-state.ts +57 -5
  30. package/src/cli/fleet-sync-forward.ts +2 -0
  31. package/src/cli/fleet-sync-manager.ts +101 -18
  32. package/src/cli/fleet-sync-remote.ts +21 -6
  33. package/src/cli/fleet-sync-state.ts +30 -11
  34. package/src/cli/fleet-sync-vault.ts +75 -26
  35. package/src/cli/fleet-sync.ts +54 -5
  36. package/src/cli/fleet-validate.ts +239 -0
  37. package/src/cli/register-fleet.ts +167 -0
  38. package/src/cli/registry.ts +47 -1
  39. package/src/definition.ts +444 -237
  40. package/src/machine/state.ts +33 -1
  41. package/src/manager-sync.ts +24 -4
  42. package/src/platform/manager-host.ts +184 -0
  43. package/src/repair-catalog.ts +64 -0
  44. package/src/repair-plan.ts +25 -13
  45. package/src/secrets.ts +3 -0
  46. package/src/state/check.ts +7 -0
  47. package/src/state/holder.ts +50 -0
  48. package/src/state/litestream.ts +6 -3
  49. package/src/state/migrations/0001-documents.sql +13 -0
  50. package/src/state/migrations/0002-lease.sql +7 -0
  51. package/src/state/store.ts +125 -26
  52. package/src/state/sync.ts +131 -10
  53. package/src/worker-credentials.ts +201 -0
  54. package/src/worker-manifest.ts +201 -0
  55. package/src/worker-policy.ts +21 -35
  56. package/src/worker-seed.ts +138 -0
@@ -0,0 +1,78 @@
1
+ # Replacing the manager
2
+
3
+ This is the one runbook for a manager that is replaced or lost: stolen, a dead
4
+ disk, or a new machine. Other documents link here and do not describe the
5
+ procedure themselves.
6
+
7
+ ## What must exist off the machine
8
+
9
+ Nothing on the old manager is needed. Before you need this runbook, make sure
10
+ these exist elsewhere:
11
+
12
+ | What | Where |
13
+ | ----------------------------------- | ------------------------------------------------------------------------ |
14
+ | Your project: `fleet.json`, the pin | Its Git remote |
15
+ | The fleet state | The bucket `fleet.json` names under `state` |
16
+ | The bucket's two credentials | Your password manager (never the manager alone) |
17
+ | Secrets | Your `secrets` provider; `file` and `env` secrets must be kept elsewhere |
18
+ | The CLI proxy's encrypted snapshots | Its restic repository (`vault` secrets provider only) |
19
+ | Your Tailscale and Docker accounts | Yours: signing in is the only step only you can do |
20
+
21
+ ## Restore
22
+
23
+ On the new machine, with Git, Node and Docker installed:
24
+
25
+ 1. Give it the manager's local host name: `manager.id` in `fleet.json`
26
+ (`sudo scutil --set LocalHostName ID` on macOS). `fleet-sync` refuses to
27
+ run on any other machine.
28
+ 2. Sign in to Tailscale and approve the device. Sign in to Docker Desktop if
29
+ your plan needs it.
30
+ 3. Fetch the project and check every prerequisite. Nothing is changed:
31
+
32
+ ```sh
33
+ git clone YOUR-PROJECT && cd YOUR-PROJECT && npm ci
34
+ export DEVFLEET_BUCKET_ACCESS_KEY_ID=… DEVFLEET_BUCKET_SECRET_ACCESS_KEY=…
35
+ npx devfleet fleet-restore --dry-run
36
+ ```
37
+
38
+ It fails on anything missing (`restore:*` checks, each with its remedy)
39
+ and lists what will be restored, in order.
40
+
41
+ 4. Restore, then converge:
42
+
43
+ ```sh
44
+ npx devfleet fleet-restore # the fleet state, from the bucket
45
+ npx devfleet fleet-sync --take-over # the manager, then every worker
46
+ npx devfleet fleet-state --init # credentials for runs the manager starts itself
47
+ npx devfleet cliproxy install # the proxy, from its snapshots
48
+ ```
49
+
50
+ The proxy's own steps are in [its migration runbook](../cliproxy/docs/migration.md).
51
+ `--take-over` is needed while the old manager's lease runs (seven days
52
+ after its last change). Use it only once the old machine is gone: an old
53
+ manager that wakes up afterwards is refused with `FLEET_MANAGER_LEASE_HELD`.
54
+
55
+ 5. Adopt the T3 instances again: `npx devfleet t3-apply --import ID`.
56
+
57
+ Workers need nothing: they reach the manager by its Tailscale name, so they
58
+ follow it once the new machine holds that name. If the Tailscale name
59
+ changed, change `manager.network.tailscaleName` in `fleet.json` and run
60
+ `fleet-sync`, which re-points every worker.
61
+
62
+ ## What is lost
63
+
64
+ The monitoring hub's metric history, alerts and settings made by hand. Its
65
+ accounts and systems are recreated. With `env` or `file` secrets, whatever
66
+ was only on the old manager.
67
+
68
+ ## Coming back by itself
69
+
70
+ A manager restarts after a power cut only when it is set up to
71
+ (`manager:boot-recovery` in `fleet-status`): `autorestart` and Docker Desktop
72
+ at login are applied by `ansible/macos/manager.yml`. The per-user services
73
+ need a user session. **With FileVault on, a Mac waits at the login window
74
+ after every restart until a person signs in**, so nothing comes back
75
+ unattended. That is the trade-off: FileVault protects the proxy's logins and
76
+ the bucket credentials on a stolen Mac; automatic login without FileVault
77
+ lets the fleet recover from a power cut alone. `fleet-status` reports
78
+ FileVault as a warning with that consequence; the choice is yours.
@@ -0,0 +1,27 @@
1
+ # What only a person does on the manager
2
+
3
+ Everything on the manager is declared and converged by `fleet-sync`: the
4
+ toolchain, the power policy (applied by `ansible/macos/manager.yml` on every
5
+ run), the CLI proxy, the monitoring hub and their jobs. What remains are the
6
+ steps that need a person: an identity, an approval or an administrator
7
+ password. Each is done once, and `fleet-sync` measures it afterwards with the
8
+ check named beside it.
9
+
10
+ The list is tested not to grow (`features/rules/manager-setup.feature`): a
11
+ step added here must be added there too, with the check that verifies it.
12
+
13
+ <!-- human-steps -->
14
+
15
+ | Step | Why a person | Verified by |
16
+ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------- | --------------------- |
17
+ | Install Docker Desktop at the release `mise.toml` pins and sign in to it | A licence and an account | `manager:docker` |
18
+ | Sign this Mac in to Tailscale and approve it | A tailnet account and device approval | `tailscale:discovery` |
19
+ | Pass the bucket credentials (`DEVFLEET_BUCKET_*`) or save them with `fleet-state --init` | Credentials of an account you own | `state:bucket` |
20
+ | Log the manager in to Vault | A password or an identity provider | `auth:vault` |
21
+ | Grant the power settings once: `ansible-playbook -e user=$(id -un) -K ansible/macos/manager-privilege.yml` | An administrator password | `manager:power` |
22
+
23
+ <!-- /human-steps -->
24
+
25
+ The grant lets the manager's user run exactly `pmset -c` without a password,
26
+ so later syncs never prompt. Run the same playbook with `-e grant=absent` to
27
+ remove it.
@@ -304,6 +304,14 @@ The public CLI exposes `devfleet fleet-repair --list` (static catalog) and
304
304
  in this package only executes allowlisted existing owner commands. Native
305
305
  account logins and unknown/manual checks always remain operator steps.
306
306
 
307
+ Every check ID has an owner in `src/check-owners.ts`: a recipe, or the reason
308
+ only a person can repair it, which the plan prints beside the check
309
+ (`manual`). Machine repairs are one tag of a playbook the worker already
310
+ applies: `recovery` (`worker:service-recovery`), `disk`
311
+ (`worker:capacity:*`), `monitoring` (the Beszel agent) and `network` (the
312
+ Tailscale daemon). `state:*` checks take the fleet state from the bucket
313
+ again. A feature fails when a check is reported that has no owner.
314
+
307
315
  Use `--apply --yes` only after review; `--plan-hash` binds application to the
308
316
  previewed steps, targets and checkout/shared-input fingerprints. The remote
309
317
  protocol independently requires explicit apply, a supported repair action and
package/docs/runbooks.md CHANGED
@@ -8,7 +8,7 @@ person (a login, an approval, a choice) is marked **needs a person**.
8
8
  `features/rules/runbooks.feature` fails when a code in the source has no
9
9
  entry here, or a `devfleet` command quoted in the documents does not exist.
10
10
 
11
- There are 244 codes; 31 need a person.
11
+ There are 250 codes; 31 need a person.
12
12
 
13
13
  ## Bootstrap of a new or returning worker
14
14
 
@@ -346,6 +346,20 @@ There are 244 codes; 31 need a person.
346
346
  - `VAULT_LOGIN_REQUIRED`: vault login required. **Needs a person.**
347
347
  - `VAULT_POLICY_PERMISSION_REQUIRED`: vault policy permission required. **Needs a person.**
348
348
  - `VAULT_RENEWAL_FAILING`: vault renewal failing.
349
+ - `VAULT_WORKER_POLICY_FAILED`: vault worker policy failed.
350
+
351
+ ## The fleet definition and a worker's lifecycle
352
+
353
+ - **Meaning:** `fleet.json` breaks a rule of the fleet definition, the pinned Ansible rejects the inventory it describes, or a seed, replacement or retirement could not change the host keys the manager trusts.
354
+ - **Confirm:** `devfleet fleet-validate` names the field that breaks a rule as `{code, path, expected, actual}`; `devfleet fleet-validate --ansible` also renders the inventory and syntax-checks every playbook.
355
+ - **Fix:** Correct the field the check names in `fleet.json`, then run the command again. For SSH_HOST_KEY_NOT_CHANGED run `devfleet fleet-validate --ansible`, then repeat `devfleet fleet-node-seed`, `devfleet fleet-node-replace` or `devfleet fleet-node-retire`.
356
+ - **Escalate:** An inventory the pinned Ansible rejects for a valid definition is an issue.
357
+
358
+ - `DEFINITION_INVALID`: definition invalid.
359
+ - `DEFINITION_INVENTORY_REJECTED`: definition inventory rejected.
360
+ - `DEFINITION_MISSING`: definition missing.
361
+ - `DEFINITION_UNREADABLE`: definition unreadable.
362
+ - `SSH_HOST_KEY_NOT_CHANGED`: ssh host key not changed.
349
363
 
350
364
  ## Fleet operations and the fleet state
351
365
 
package/docs/state.md CHANGED
@@ -64,9 +64,9 @@ arguments and never written to a report or a log.
64
64
 
65
65
  **A new fleet.** Nothing more: the first `fleet-sync` seeds the bucket.
66
66
 
67
- **A replacement manager, or a second machine.** Clone your project, `npm ci`,
68
- and run `fleet-sync` with the credentials. The bucket already holds the fleet,
69
- so its state is downloaded before anything is changed.
67
+ **A replacement manager.** The bucket already holds the fleet, so its state is
68
+ downloaded before anything is changed. Follow
69
+ [replacing the manager](manager-recovery.md).
70
70
 
71
71
  **Runs the manager starts by itself.** A worker that hands `fleet-sync` to the
72
72
  manager starts a command there that gets no environment and no project. Save
@@ -172,6 +172,7 @@ it records what it applied. Its report carries the result as the
172
172
  | `local-ahead` | This machine has changes the bucket has not received | The next change, or `--push`, uploads them |
173
173
  | `bucket-ahead` | Another manager changed the bucket; this copy is stale | The next change or `fleet-sync` takes the bucket's version |
174
174
  | `unreachable` | The bucket did not answer, or did not take an upload | Changes still apply locally and are uploaded later |
175
+ | `corrupt` | The bucket answered with a version that cannot be read | Changes apply locally; `--restore --timestamp` repairs it |
175
176
  | `diverged` | Both sides changed | Refused. `--restore --yes` takes the bucket's version |
176
177
 
177
178
  A stale copy never overwrites the bucket. A machine that was offline while
@@ -210,6 +211,7 @@ once `fleet-sync` has fetched it back.
210
211
  | `DEVFLEET_CONFIG` | A configuration file, instead of the project's `fleet.json` |
211
212
  | `DEVFLEET_STATE` | Another state directory. Earlier files are not read for it |
212
213
  | `DEVFLEET_BUCKET` | Another `bucket.json` |
214
+ | `DEVFLEET_MANAGER_HOLDER` | How the manager lease names this machine |
213
215
 
214
216
  How a command reaches the bucket:
215
217
 
@@ -254,7 +256,37 @@ mise exec litestream -- litestream restore -no-expand-env \
254
256
  ~/.local/share/dev-fleet/state/fleet.db
255
257
  ```
256
258
 
259
+ `devfleet fleet-state --restore --timestamp 2026-11-01T00:00:00Z --yes` does
260
+ the same and then uploads that version as the bucket's current one. It is the
261
+ remedy for a `corrupt` replica: the bucket answers, but its latest version
262
+ cannot be read.
263
+
257
264
  Litestream is pinned in `mise.toml` and installed the first time it is used.
258
- The manager is the only machine that runs it. There is one manager, so there is
259
- one writer; two machines changing the state at the same moment are not
260
- supported, only detected afterwards.
265
+ The manager is the only machine that runs it.
266
+
267
+ ## One manager at a time
268
+
269
+ The fleet state carries a **manager lease**: which machine is the manager,
270
+ and until when. Every change takes or renews it, and it reaches the bucket
271
+ with the change. A second machine (a replacement while the old manager still
272
+ runs, or an old manager that wakes up) reads it from the bucket before it
273
+ writes anything and stops with `FLEET_MANAGER_LEASE_HELD`. `fleet-state
274
+ --init` still restores the state on such a machine; it only writes nothing.
275
+
276
+ The lease runs for seven days after the last change. After that, any machine
277
+ may take it. Before that, `--take-over` (on `fleet-sync`, `fleet-state --init`
278
+ or `fleet-state --import`) makes this machine the manager; use it only once
279
+ the other one is gone ([replacing the manager](manager-recovery.md)).
280
+
281
+ A machine is named in the lease by a digest of its machine ID
282
+ (`/etc/machine-id` on Linux, the hardware UUID on macOS), never by its host
283
+ name, which a replacement shares. `DEVFLEET_MANAGER_HOLDER` names it instead.
284
+
285
+ ## Schema version
286
+
287
+ The database records its schema version in `PRAGMA user_version`. The schema
288
+ is a series of numbered migrations (`src/state/migrations/0001-….sql`, …),
289
+ and a command brings an older database forward before it writes. A database a
290
+ newer dev-fleet wrote is refused with `FLEET_STATE_NEWER_THAN_CLI`, read or
291
+ written, and nothing in it changes: install the dev-fleet version the fleet
292
+ runs.
package/docs/topology.md CHANGED
@@ -35,13 +35,17 @@ your project's `fleet.json` (version 3, checked by [the schema](../schema/fleet.
35
35
  A new singleton service is declared under `services` and placed on the
36
36
  manager; it never names a host.
37
37
  - Workers depend on it, so it never sleeps on AC power
38
- (`pmset` in `ansible/macos/policy.json`, checked as `manager:power`, applied
39
- once by an administrator with `ansible/macos/manager.yml`; the failed check
40
- prints the command). While the
38
+ (`pmset` in `ansible/macos/policy.json`, checked as `manager:power` and
39
+ applied by `ansible/macos/manager.yml` on every `fleet-sync` once an
40
+ administrator has given the one grant `ansible/macos/manager-privilege.yml`;
41
+ the failed check prints the command, and
42
+ [docs/manager-setup.md](manager-setup.md) lists every step only a person
43
+ takes). While the
41
44
  manager is asleep, closed or offline, workers have no CLI proxy and no
42
45
  dashboard; nothing fails over, because a singleton never runs twice.
43
46
  - Its state (proxy OAuth/runtime state, the hub database) is backed up off the
44
- machine as each service documents, so a replacement manager restores it.
47
+ machine as each service documents, so a replacement manager restores it
48
+ ([replacing the manager](manager-recovery.md)).
45
49
 
46
50
  ## Workers
47
51
 
@@ -55,15 +59,19 @@ your project's `fleet.json` (version 3, checked by [the schema](../schema/fleet.
55
59
  checkouts. Durable work lives in Git remotes; credentials come from Vault.
56
60
  Work in progress in agent worktrees is pushed to a recovery ref every 15
57
61
  minutes; [durability](durability.md) says what a lost machine takes with it.
58
- - The first declared worker is the rollout canary for a revision new to the
59
- fleet: a failed canary stops promotion to the other workers. Repeating a
60
- revision that already converged everywhere runs all workers at once.
62
+ - One worker is the rollout canary for a revision new to the fleet: the one
63
+ `fleet-sync --canary` or `canary` in `fleet.json` names, else the first
64
+ declared worker. When it cannot be reached (powered off, being rebuilt),
65
+ the next reachable worker in declared order proves the revision instead and
66
+ the unreachable one is reported as offline, never as a blocked rollout. A
67
+ canary that was deployed and failed stops promotion to the other workers.
68
+ Each worker's applied revision is kept in the fleet state, so one that
69
+ converged keeps its progress when another did not. Repeating a revision
70
+ that already converged everywhere runs all workers at once.
61
71
 
62
72
  ## Changing the topology
63
73
 
64
74
  - **Add or replace a worker:** add it to `workers`, enroll it with
65
75
  `fleet-sync`, done. Remove it by deleting the entry.
66
- - **Replace the manager:** edit `manager`, then move each singleton service with
67
- its documented fence-and-restore runbook (never two copies at once):
68
- [CLI proxy](../cliproxy/docs/migration.md),
69
- [Beszel hub](../ansible/monitoring/README.md).
76
+ - **Replace the manager:** follow
77
+ [replacing the manager](manager-recovery.md).
@@ -188,26 +188,32 @@ verification and the CLIProxy singleton.
188
188
  | Tailscale | pinned APT package, `tailscaled` enabled, native login required | Tailscale app, human login |
189
189
  | Dashboard agent | pinned native binary, hardened `beszel-agent` systemd unit as user `beszel` | not installed |
190
190
  | Credential renewal | user timer `personal-worker-auth` renews the node's own Vault token every 10min; `personal-worker-github` signs it in to GitHub from the stored token ([GitHub](github.md)) | the human's own Vault and GitHub sessions |
191
- | Noninteractive sudo | validated `/etc/sudoers.d/personal-fleet-USER` | never; the human runs `manager.yml` once with `-K` |
191
+ | Noninteractive sudo | validated `/etc/sudoers.d/personal-fleet-USER` | only `pmset -c`, granted once by `manager-privilege.yml` (`-K`) |
192
192
  | T3 worker app | pinned desktop `.deb` is the only T3 process (UI plus embedded server); GDM auto-login, user unit `t3code-desktop.service` bound to the graphical session with `Restart=always`; `personal-worker-recovery` runs a copy of `t3-health.sh` installed in `/usr/local/lib/personal-fleet` every minute: it stops and disables a still-running headless `t3code.service`, reports `T3_NO_GRAPHICAL_SESSION` and restarts the display manager (three times at most) when there is no session, starts a stopped app after `reset-failed`, restarts one whose local API missed three checks, and restarts one on another release than the installed one or with a deferred policy restart once it runs no agent turn; administrator policy | not a worker |
193
193
  | T3 providers | the same managed instances (`fleet/agents/t3.json`) through locked T3 administrator policy | reconciled into the human's T3 settings by `fleet-sync` (`t3:providers`) with the ownership-safe `t3-apply` engine, so every T3 offers the identical set; unrelated and unmanaged instances are preserved. `fleet-sync` also applies the same proxy agent profiles (`configuration:apply`) and verifies the shared Vault gateway credential end to end with `provider:*:inference`, exactly as on workers |
194
194
  | Singleton services | never; clients of the manager's services only | every singleton runs here: CLI proxy (`cliproxy`), Beszel hub (`workstation/monitoring`) |
195
195
 
196
196
  Credential sync is deliberately indirect: no secret is copied between machines.
197
- OpenTofu issues each enrolled worker its own renewable periodic token
198
- (`vault.tokens` entries `fleet-worker-<node>`, policy `personal-read`, sunk into
199
- `secret/personal/prd` as `FLEET_WORKER_TOKEN_<NODE>`). `devfleet fleet-sync`
200
- delivers each node's token automatically over SSH stdin (never as an argument)
201
- whenever that node has no working Vault session. Interactive runs first log the
202
- manager in (`vault login -method=userpass`, password typed on your terminal)
203
- and fall back to the same password login on a node whose token is not issued.
204
- Without either, the node reports `VAULT_LOGIN_REQUIRED` and its T3 proxy
205
- providers stay unavailable. The manual equivalent of the automatic delivery:
206
-
207
- ```sh
208
- vault kv get -field=FLEET_WORKER_TOKEN_WORKER_ONE secret/personal/prd \
209
- | ssh worker-one 'vault login -address=https://vault.example.com -no-print -'
210
- ```
197
+ Each worker has a Vault token of its own, kept in a document of its own,
198
+ `<mount>/<project>/workers/<id>` (fields `token` and `accessor`; `<project>`
199
+ is the first segment of the `proxy-client-key` path). The manager issues it
200
+ with `devfleet fleet-node-replace --id ID --apply --yes`: a periodic orphan
201
+ token under the policy `fleet-worker-<id>`, which reads the fleet's shared
202
+ document and the worker's own document and nothing else, so one worker's
203
+ token cannot read another's (`features/fleet-sync/worker-credentials.feature`
204
+ proves it against a Vault development server). The manager's own login needs
205
+ to write policies, create tokens and write the mount.
206
+
207
+ `devfleet fleet-sync` delivers each node's token over SSH stdin (never as an
208
+ argument) whenever that node has no working Vault session. A token still kept
209
+ in the shared document as `FLEET_WORKER_TOKEN_<NODE>` is delivered too, with
210
+ the warning `VAULT_WORKER_TOKEN_SHARED`: every worker can read that document,
211
+ so issue the worker its own token and remove the field. A password login with
212
+ the fleet's shared Vault user on the worker's own terminal happens only with
213
+ `fleet-sync --vault-password-login`. Without a token the node reports
214
+ `VAULT_LOGIN_REQUIRED` and its T3 proxy providers stay unavailable.
215
+ `devfleet fleet-node-retire --id ID --apply --yes` revokes a worker's token by
216
+ its accessor and removes its document and policy.
211
217
 
212
218
  From then on the node's `personal-worker-auth` timer renews that token and its
213
219
  `personal-worker-github` timer keeps its GitHub sign-in current
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crvouga/dev-fleet",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Keeps a set of development machines in sync: one manager and many workers",
5
5
  "license": "MIT",
6
6
  "repository": "github:crvouga/dev-fleet",
@@ -72,5 +72,5 @@
72
72
  "engines": {
73
73
  "node": ">=22"
74
74
  },
75
- "gitHead": "9f9718f2853ed7840c846c8dfc8750903c782b87"
75
+ "gitHead": "3f13ebe4bda2cf813a68ee45678627b2e21660d5"
76
76
  }
@@ -66,6 +66,14 @@
66
66
  "id": "auth:vault",
67
67
  "meaning": "This machine has its own Vault session."
68
68
  },
69
+ {
70
+ "id": "auth:vault-isolation",
71
+ "meaning": "A worker reads its Vault token from a document of its own, not from the fleet's shared one."
72
+ },
73
+ {
74
+ "id": "auth:worker-token",
75
+ "meaning": "A worker has a Vault token of its own that only it and the manager can read, or a retired worker's token is revoked."
76
+ },
69
77
  {
70
78
  "id": "beszel:*",
71
79
  "meaning": "A monitoring (Beszel) check of this machine."
@@ -150,6 +158,18 @@
150
158
  "id": "configuration:operation",
151
159
  "meaning": "The remote configuration operation completed."
152
160
  },
161
+ {
162
+ "id": "definition:inventory",
163
+ "meaning": "Ansible reads the inventory the fleet definition describes."
164
+ },
165
+ {
166
+ "id": "definition:playbooks",
167
+ "meaning": "Every playbook passes ansible-playbook --syntax-check."
168
+ },
169
+ {
170
+ "id": "definition:schema",
171
+ "meaning": "fleet.json follows every rule of the fleet definition."
172
+ },
153
173
  {
154
174
  "id": "enrollment:connection",
155
175
  "meaning": "The manager has an enrolled connection to this worker."
@@ -218,6 +238,10 @@
218
238
  "id": "login:*",
219
239
  "meaning": "A native per-machine login is in place."
220
240
  },
241
+ {
242
+ "id": "manager:boot-recovery",
243
+ "meaning": "The manager comes back by itself after a power cut or reboot."
244
+ },
221
245
  {
222
246
  "id": "manager:docker",
223
247
  "meaning": "The manager runs the pinned Docker Desktop."
@@ -302,6 +326,10 @@
302
326
  "id": "rollout:evidence",
303
327
  "meaning": "A worker's rollout produced verification evidence."
304
328
  },
329
+ {
330
+ "id": "rollout:next",
331
+ "meaning": "Names the command that converges a worker after it was replaced."
332
+ },
305
333
  {
306
334
  "id": "rollout:operation",
307
335
  "meaning": "The remote rollout operation completed."
@@ -386,6 +414,10 @@
386
414
  "id": "state:replica",
387
415
  "meaning": "The bucket holds this manager's fleet state."
388
416
  },
417
+ {
418
+ "id": "state:retired",
419
+ "meaning": "The fleet state records that a retired worker was retired."
420
+ },
389
421
  {
390
422
  "id": "t3:api",
391
423
  "meaning": "The local T3 API answers."
@@ -534,6 +566,10 @@
534
566
  "id": "worker:package:*",
535
567
  "meaning": "The pinned Ubuntu package is installed at or past its pin."
536
568
  },
569
+ {
570
+ "id": "worker:manifest",
571
+ "meaning": "A worker is made of what the declared pins and a fresh build of the same revision are made of."
572
+ },
537
573
  {
538
574
  "id": "worker:power-loss-recovery",
539
575
  "meaning": "The worker powers on after AC loss."
@@ -58,7 +58,6 @@
58
58
  },
59
59
  "workers": {
60
60
  "type": "array",
61
- "minItems": 1,
62
61
  "maxItems": 64,
63
62
  "items": {
64
63
  "type": "object",
@@ -68,7 +67,7 @@
68
67
  "id": {
69
68
  "type": "string",
70
69
  "pattern": "^[a-z][a-z0-9-]{0,62}$",
71
- "description": "A short stable id for the worker. The first worker listed is the canary: a revision new to the fleet must pass there before the others receive it."
70
+ "description": "A short stable id for the worker. A revision new to the fleet must pass on one worker, the canary, before the others receive it: the one named under canary, or else the first reachable worker in this order."
72
71
  },
73
72
  "ssh": {
74
73
  "type": "string",
@@ -119,6 +118,11 @@
119
118
  }
120
119
  }
121
120
  },
121
+ "canary": {
122
+ "type": "string",
123
+ "pattern": "^[a-z][a-z0-9-]{0,62}$",
124
+ "description": "The id of the worker that proves a revision new to the fleet first. When it is unreachable, the next reachable worker proves it instead. Default: the first reachable worker."
125
+ },
122
126
  "services": {
123
127
  "type": "object",
124
128
  "additionalProperties": false,
@@ -626,6 +630,22 @@
626
630
  "pattern": "^[A-Za-z0-9_]+$"
627
631
  }
628
632
  }
633
+ },
634
+ "tailnet-auth-key": {
635
+ "type": "object",
636
+ "additionalProperties": false,
637
+ "required": ["path", "field"],
638
+ "properties": {
639
+ "path": {
640
+ "type": "string",
641
+ "pattern": "^[A-Za-z0-9._/-]+$"
642
+ },
643
+ "field": {
644
+ "type": "string",
645
+ "pattern": "^[A-Za-z0-9_]+$"
646
+ }
647
+ },
648
+ "description": "Optional: a short-lived, single-use, tagged Tailscale auth key a new worker joins the tailnet with (fleet-node-seed)."
629
649
  }
630
650
  },
631
651
  "description": "Required for every secret except github-token, which defaults to the field GITHUB_TOKEN beside proxy-client-key."
@@ -0,0 +1,164 @@
1
+ /** Who repairs a check: a repair recipe (`repair`), or the reason only a
2
+ * person can (`manual`). Every check ID dev-fleet reports matches one entry,
3
+ * by its exact ID or by a prefix ending in `*` (`beszel:*`). The most
4
+ * specific match wins. `login:*` is the sign-in recipe named after the rest
5
+ * of an `auth:` check's ID.
6
+ */
7
+ export interface CheckOwner {
8
+ pattern: string;
9
+ repair?: string;
10
+ manual?: string;
11
+ }
12
+
13
+ const IDENTITY =
14
+ 'It needs a person: a sign-in, an approval or a choice only the operator can make.';
15
+ const EVIDENCE =
16
+ 'It reports how a command or a run went, not the state of a machine: repeat the command once its remedy is followed.';
17
+ const DECLARATION =
18
+ 'The fleet configuration (fleet.json) or the roster is wrong: only the operator edits them.';
19
+ const HARDWARE =
20
+ 'It is a property of the hardware, the firmware or the operating system release, which no command changes.';
21
+
22
+ export const CHECK_OWNERS: CheckOwner[] = [
23
+ // Toolchain and workstation: automatic.
24
+ { pattern: 'tool:*', repair: 'workspace' },
25
+ { pattern: 'tool:docker-*', repair: 'docker' },
26
+ { pattern: 'tool:github-auth*', repair: 'login:github' },
27
+ { pattern: 'dependencies', repair: 'workspace' },
28
+ { pattern: 'auth:toolchain', repair: 'workspace' },
29
+ { pattern: 'workstation:*', repair: 'workstation' },
30
+ { pattern: 'auth:*', repair: 'login:*' },
31
+ { pattern: 'auth:vscode', manual: IDENTITY },
32
+ { pattern: 'auth:workstation', manual: IDENTITY },
33
+ { pattern: 't3:settings', repair: 't3' },
34
+ { pattern: 't3:api', repair: 't3' },
35
+ // The checks that fail when a machine breaks.
36
+ { pattern: 'worker:service-recovery', repair: 'recovery' },
37
+ { pattern: 'worker:capacity', repair: 'disk' },
38
+ { pattern: 'worker:capacity:*', repair: 'disk' },
39
+ { pattern: 'beszel:agent', repair: 'monitoring' },
40
+ { pattern: 'beszel:service', repair: 'monitoring' },
41
+ { pattern: 'beszel:installation', repair: 'monitoring' },
42
+ { pattern: 'beszel:protocol', repair: 'monitoring' },
43
+ { pattern: 'tailscale:configuration', repair: 'network' },
44
+ { pattern: 'tailscale:online', repair: 'network' },
45
+ { pattern: 'tailscale:reachability', repair: 'network' },
46
+ { pattern: 'state:replica', repair: 'state' },
47
+ { pattern: 'state:*', repair: 'state' },
48
+ // What only a person can do.
49
+ {
50
+ pattern: 'beszel:*',
51
+ manual:
52
+ 'The monitoring hub and its accounts are converged by fleet-sync on the manager; follow the remedy, then run fleet-sync.',
53
+ },
54
+ {
55
+ pattern: 'tailscale:*',
56
+ manual:
57
+ 'Signing a machine in to the tailnet and approving it is a person’s; the daemon itself is repaired by the network recipe.',
58
+ },
59
+ {
60
+ pattern: 'state:bucket',
61
+ manual:
62
+ 'The bucket and its credentials are the operator’s: name them in fleet.json and pass the credentials.',
63
+ },
64
+ { pattern: 'state:definition', manual: DECLARATION },
65
+ {
66
+ pattern: 'worker:*',
67
+ manual:
68
+ 'Re-converge the worker with fleet-sync: it applies the whole worker policy at the approved revision.',
69
+ },
70
+ { pattern: 'worker:power-loss-recovery', manual: HARDWARE },
71
+ { pattern: 'worker:github-principal', manual: IDENTITY },
72
+ { pattern: 'worker:vault-renewal', manual: IDENTITY },
73
+ {
74
+ pattern: 'manager:*',
75
+ manual:
76
+ 'The manager converges itself in fleet-sync; a setting that needs an administrator is applied with ansible-playbook -K ansible/macos/manager.yml.',
77
+ },
78
+ { pattern: 'manager:identity', manual: DECLARATION },
79
+ {
80
+ pattern: 'manager:docker',
81
+ manual:
82
+ 'Docker Desktop is installed and updated by a person; no playbook installs it.',
83
+ },
84
+ { pattern: 'macos:*', manual: HARDWARE },
85
+ { pattern: 'ubuntu:release', manual: HARDWARE },
86
+ {
87
+ pattern: 'ubuntu:*',
88
+ manual:
89
+ 'Re-converge the worker with fleet-sync: it applies the Ubuntu baseline.',
90
+ },
91
+ { pattern: 'platform:*', manual: HARDWARE },
92
+ {
93
+ pattern: 'ssh:*',
94
+ manual:
95
+ 'SSH trust (host keys, keys in the agent, the alias) is set up by a person once per machine; dev-fleet never accepts a host key for you.',
96
+ },
97
+ { pattern: 'login:*', manual: IDENTITY },
98
+ { pattern: 'provider:*', manual: IDENTITY },
99
+ {
100
+ pattern: 'agent:*',
101
+ manual:
102
+ 'Re-converge the machine with fleet-sync; an agent that needs a sign-in is a person’s.',
103
+ },
104
+ {
105
+ pattern: 't3:*',
106
+ manual:
107
+ 'T3 pairing and its private route are approved by a person; fleet-sync applies the rest.',
108
+ },
109
+ { pattern: 'vscode:*', manual: IDENTITY },
110
+ {
111
+ pattern: 'git:*',
112
+ manual:
113
+ 'Uncommitted or unpushed work belongs to a person; dev-fleet never resets a checkout.',
114
+ },
115
+ {
116
+ pattern: 'git:configuration',
117
+ manual:
118
+ 'fleet-sync writes the Git identity and credential helper with ansible/github/git.yml; run it from the manager.',
119
+ },
120
+ {
121
+ pattern: 'github:*',
122
+ manual:
123
+ 'fleet-sync signs every node in to GitHub from the stored github-token (a worker retries every ten minutes); a token that is missing, expired or short of a scope or organisation needs a person to issue a new one.',
124
+ },
125
+ {
126
+ pattern: 'github:git-credential',
127
+ manual:
128
+ 'fleet-sync writes the GitHub credential helper with ansible/github/git.yml when the git block is declared; run it from the manager.',
129
+ },
130
+ { pattern: 'github:rate-limit', manual: EVIDENCE },
131
+ { pattern: 'github:workflow', manual: EVIDENCE },
132
+ { pattern: 'fleet:*', manual: DECLARATION },
133
+ { pattern: 'definition:*', manual: DECLARATION },
134
+ { pattern: 'enrollment:*', manual: DECLARATION },
135
+ { pattern: 'notify:*', manual: DECLARATION },
136
+ {
137
+ pattern: 'proxy:*',
138
+ manual:
139
+ 'The CLI proxy is installed with devfleet cliproxy install and its accounts are signed in by a person.',
140
+ },
141
+ {
142
+ pattern: 'accounts:roster-current',
143
+ manual:
144
+ 'The roster reaches every node with devfleet fleet-sync --only accounts, run from the manager.',
145
+ },
146
+ { pattern: 'accounts:native-login', manual: IDENTITY },
147
+ { pattern: 'configuration:*', manual: EVIDENCE },
148
+ { pattern: 'source:*', manual: EVIDENCE },
149
+ { pattern: 'bootstrap:*', manual: EVIDENCE },
150
+ { pattern: 'rollout:*', manual: EVIDENCE },
151
+ { pattern: 'repair:*', manual: EVIDENCE },
152
+ ];
153
+
154
+ const matches = (pattern: string, id: string): boolean =>
155
+ pattern.endsWith('*') ? id.startsWith(pattern.slice(0, -1)) : id === pattern;
156
+
157
+ /** The entry that owns a check, or null for an ID nobody registered. */
158
+ export function checkOwner(id: string): CheckOwner | null {
159
+ return (
160
+ CHECK_OWNERS.filter((owner) => matches(owner.pattern, id)).sort(
161
+ (a, b) => b.pattern.length - a.pattern.length
162
+ )[0] ?? null
163
+ );
164
+ }