@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.
- package/README.md +32 -13
- package/ansible/macos/manager-privilege.yml +37 -0
- package/ansible/macos/manager.yml +51 -6
- package/ansible/macos/policy.json +6 -1
- package/ansible/macos/worker-host-key.yml +33 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/ubuntu/baseline.yml +6 -0
- package/ansible/ubuntu/recovery.yml +2 -0
- package/ansible/ubuntu/tailscale.yml +2 -0
- package/bootstrap/worker-seed.yaml +61 -0
- package/docs/authentication.md +2 -0
- package/docs/consumer-setup.md +34 -35
- package/docs/declared.md +24 -24
- package/docs/manager-recovery.md +78 -0
- package/docs/manager-setup.md +27 -0
- package/docs/operations.md +8 -0
- package/docs/runbooks.md +15 -1
- package/docs/state.md +38 -6
- package/docs/topology.md +19 -11
- package/docs/ubuntu-sync.md +21 -15
- package/package.json +2 -2
- package/schema/check-ids.json +36 -0
- package/schema/fleet.schema.json +22 -2
- package/src/check-owners.ts +164 -0
- package/src/cli/fleet-manifest.ts +102 -0
- package/src/cli/fleet-node-lifecycle.ts +455 -0
- package/src/cli/fleet-node-seed.ts +151 -0
- package/src/cli/fleet-restore.ts +205 -0
- package/src/cli/fleet-state.ts +57 -5
- package/src/cli/fleet-sync-forward.ts +2 -0
- package/src/cli/fleet-sync-manager.ts +101 -18
- package/src/cli/fleet-sync-remote.ts +21 -6
- package/src/cli/fleet-sync-state.ts +30 -11
- package/src/cli/fleet-sync-vault.ts +75 -26
- package/src/cli/fleet-sync.ts +54 -5
- package/src/cli/fleet-validate.ts +239 -0
- package/src/cli/register-fleet.ts +167 -0
- package/src/cli/registry.ts +47 -1
- package/src/definition.ts +444 -237
- package/src/machine/state.ts +33 -1
- package/src/manager-sync.ts +24 -4
- package/src/platform/manager-host.ts +184 -0
- package/src/repair-catalog.ts +64 -0
- package/src/repair-plan.ts +25 -13
- package/src/secrets.ts +3 -0
- package/src/state/check.ts +7 -0
- package/src/state/holder.ts +50 -0
- package/src/state/litestream.ts +6 -3
- package/src/state/migrations/0001-documents.sql +13 -0
- package/src/state/migrations/0002-lease.sql +7 -0
- package/src/state/store.ts +125 -26
- package/src/state/sync.ts +131 -10
- package/src/worker-credentials.ts +201 -0
- package/src/worker-manifest.ts +201 -0
- package/src/worker-policy.ts +21 -35
- 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.
|
package/docs/operations.md
CHANGED
|
@@ -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
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
-
|
|
59
|
-
fleet
|
|
60
|
-
|
|
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:**
|
|
67
|
-
|
|
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).
|
package/docs/ubuntu-sync.md
CHANGED
|
@@ -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` |
|
|
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
|
-
|
|
198
|
-
(`
|
|
199
|
-
`
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
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": "
|
|
75
|
+
"gitHead": "3f13ebe4bda2cf813a68ee45678627b2e21660d5"
|
|
76
76
|
}
|
package/schema/check-ids.json
CHANGED
|
@@ -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."
|
package/schema/fleet.schema.json
CHANGED
|
@@ -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.
|
|
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
|
+
}
|