@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
package/README.md CHANGED
@@ -128,8 +128,10 @@ npm install --save-dev --save-exact @crvouga/dev-fleet@latest
128
128
  npx devfleet fleet-sync # the fleet moves to that release
129
129
  ```
130
130
 
131
- Install an older version and run `fleet-sync` to go back. The first worker
132
- proves a new revision before the others receive it.
131
+ Install an older version and run `fleet-sync` to go back. One worker, the
132
+ canary, proves a new revision before the others receive it: the first
133
+ reachable worker, or the one `canary` in `fleet.json` (or `--canary`) names
134
+ when it is reachable. An offline worker never holds the others back.
133
135
 
134
136
  ## Your project's own tools
135
137
 
@@ -170,16 +172,10 @@ says where the bucket is and the command is passed its credentials:
170
172
  npx devfleet fleet-state # where the state is, and how it stands with the bucket
171
173
  ```
172
174
 
173
- The copy on the manager is disposable. **To replace the manager**, clone your
174
- project on the new machine and run the same two commands:
175
-
176
- ```sh
177
- npm ci
178
- npx devfleet fleet-sync # with the two bucket credentials in the environment
179
- ```
180
-
181
- The roster and the approved revision come back from the bucket, and the
182
- machine is rebuilt from Git.
175
+ The copy on the manager is disposable: the roster and the approved revision
176
+ come back from the bucket, and the machine is rebuilt from Git.
177
+ [Replacing the manager](docs/manager-recovery.md) is the runbook for a new or
178
+ lost manager.
183
179
 
184
180
  Some runs are started by a machine rather than by you: a worker that hands
185
181
  `fleet-sync` to the manager, for example. They get no environment, so save the
@@ -228,7 +224,26 @@ propagates them without a full sync.
228
224
  Ubuntu machine, over SSH
229
225
  ([`fresh-box.feature`](features/fresh-box/fresh-box.feature)).
230
226
 
231
- Remove a machine by deleting its entry and running `fleet-sync`.
227
+ A new machine can also join by itself: `npx devfleet fleet-node-seed --id ID
228
+ --output seed.yaml --apply --yes` writes cloud-init user data (or an Ubuntu
229
+ autoinstall file with `--autoinstall`) holding the worker account with your
230
+ manager's key, the privilege rule, a host key the manager pins at once and the
231
+ pinned Tailscale with a short-lived single-use auth key from your secrets
232
+ provider (`tailnet-auth-key`). Boot a stock Ubuntu image with it, then add the
233
+ worker with `fleet-node-add` and run `fleet-sync`.
234
+
235
+ `npx devfleet fleet-manifest --id ID` measures what a worker is made of
236
+ (packages, active tools, always-on services) with a digest that is the same
237
+ for two workers built from the same revision; its check `worker:manifest`
238
+ names every difference from the declared pins and, with `--reference` (the
239
+ `--json` manifest of a fresh build), from that build.
240
+
241
+ Remove a machine with `npx devfleet fleet-node-retire --id ID --apply --yes`
242
+ (it revokes the worker's Vault token, forgets its host key and roster entry
243
+ and leaves a tombstone in the fleet state), then delete its entry and run
244
+ `fleet-sync`. To put a fresh machine in place of a worker under the same id,
245
+ run `npx devfleet fleet-node-replace --id ID --host-key "TYPE BASE64" --apply
246
+ --yes` and then `fleet-sync --node ID`.
232
247
 
233
248
  ## Configuration
234
249
 
@@ -237,6 +252,10 @@ Your `fleet.json` is validated by
237
252
  package carries: `fleet-state --example` prints a complete example that points
238
253
  `$schema` at it, so an editor checks the file as you type.
239
254
  [`examples/fleet.json`](examples/fleet.json) is the same example.
255
+ `devfleet fleet-validate` checks the file without contacting anything and
256
+ names the first field that breaks a rule (`{code, path, expected, actual}`);
257
+ `--ansible` also renders the Ansible inventory and syntax-checks every
258
+ playbook with the pinned Ansible.
240
259
 
241
260
  | Block | What it declares |
242
261
  | ---------- | ------------------------------------------------------------------- |
@@ -0,0 +1,37 @@
1
+ ---
2
+ # The one privilege grant a manager needs, given once by its administrator:
3
+ # ansible-playbook -K manager-privilege.yml -e user=$(id -un)
4
+ # It lets that user run exactly `pmset -c` (the AC power settings
5
+ # manager.yml declares) without a password, so every fleet-sync holds the
6
+ # manager's power policy with no prompt. Run again with -e grant=absent to
7
+ # remove it; nothing else on the manager needs root.
8
+ - name: Grant the fleet manager its power settings without a prompt
9
+ hosts: localhost
10
+ connection: local
11
+ become: true
12
+ gather_facts: false
13
+ vars:
14
+ grant: present
15
+ tasks:
16
+ - name: Check the request
17
+ ansible.builtin.assert:
18
+ that:
19
+ - "user is match('^[a-z_][a-z0-9_.-]{0,31}$')"
20
+ - "user != 'root'"
21
+ - "grant in ['present', 'absent']"
22
+ - name: Allow exactly the AC power settings
23
+ ansible.builtin.copy:
24
+ dest: '/etc/sudoers.d/dev-fleet-manager-{{ user }}'
25
+ content: |
26
+ # Managed by dev-fleet: manager power policy
27
+ {{ user }} ALL=(root) NOPASSWD: /usr/bin/pmset -c *
28
+ owner: root
29
+ group: wheel
30
+ mode: '0440'
31
+ validate: /usr/sbin/visudo -cf %s
32
+ when: grant == 'present'
33
+ - name: Remove the grant
34
+ ansible.builtin.file:
35
+ path: '/etc/sudoers.d/dev-fleet-manager-{{ user }}'
36
+ state: absent
37
+ when: grant == 'absent'
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  # Manager power policy. Workers depend on the manager's singleton services
3
- # (CLI proxy, Beszel hub), so the manager never sleeps on AC power. Sleep on
4
- # battery is left to the human. The settings are the `pmset` object in
5
- # policy.json; fleet-sync measures the same object as manager:power. Changing
6
- # them needs an administrator: ansible-playbook -K manager.yml
3
+ # (CLI proxy, Beszel hub), so the manager never sleeps on AC power and starts
4
+ # again by itself when power returns (autorestart). Sleep on battery is left
5
+ # to the human. The settings are the `pmset` object in policy.json; fleet-sync
6
+ # measures the same object as manager:power and applies this playbook on every
7
+ # run, without a prompt, because `pmset -c` runs through the narrow grant
8
+ # manager-privilege.yml installs once.
7
9
  - name: Keep the fleet manager awake on AC power
8
10
  hosts: localhost
9
11
  connection: local
10
- become: true
11
12
  gather_facts: false
12
13
  vars_files:
13
14
  - policy.json
@@ -19,7 +20,15 @@
19
20
  register: pmset_custom
20
21
  - name: Apply the declared AC power settings
21
22
  ansible.builtin.command:
22
- argv: [/usr/bin/pmset, -c, '{{ item.key }}', '{{ item.value }}']
23
+ argv:
24
+ [
25
+ /usr/bin/sudo,
26
+ -n,
27
+ /usr/bin/pmset,
28
+ -c,
29
+ '{{ item.key }}',
30
+ '{{ item.value }}',
31
+ ]
23
32
  loop: '{{ pmset | dict2items }}'
24
33
  loop_control:
25
34
  label: '{{ item.key }}'
@@ -29,5 +38,41 @@
29
38
  when: observed != item.value
30
39
  changed_when: true
31
40
 
41
+ # Docker Desktop hosts the CLI proxy and the monitoring hub, so it starts at
42
+ # login: after a power cut the manager comes back without a person. The
43
+ # setting is the `dockerDesktop` object in policy.json; fleet-sync measures it
44
+ # as part of manager:boot-recovery. Only that key of the user's settings file
45
+ # is changed.
46
+ - name: Start Docker Desktop at login
47
+ hosts: localhost
48
+ connection: local
49
+ become: false
50
+ gather_facts: false
51
+ vars_files:
52
+ - policy.json
53
+ vars:
54
+ docker_settings: "{{ lookup('ansible.builtin.env', 'HOME') }}/{{ dockerDesktop.settings }}"
55
+ tasks:
56
+ - name: Find the Docker Desktop settings
57
+ ansible.builtin.stat:
58
+ path: '{{ docker_settings }}'
59
+ register: docker_settings_file
60
+ - name: Read the Docker Desktop settings
61
+ ansible.builtin.slurp:
62
+ src: '{{ docker_settings }}'
63
+ register: docker_settings_text
64
+ when: docker_settings_file.stat.exists
65
+ - name: Turn on start at login, keeping every other setting
66
+ ansible.builtin.copy:
67
+ dest: '{{ docker_settings }}'
68
+ content: "{{ current | combine({dockerDesktop.key: true}) | to_nice_json(indent=2) }}\n"
69
+ mode: '0644'
70
+ vars:
71
+ current: '{{ docker_settings_text.content | b64decode | from_json }}'
72
+ # Docker Desktop writes the file the first time it runs.
73
+ when:
74
+ - docker_settings_file.stat.exists
75
+ - (current[dockerDesktop.key] | default(false)) != true
76
+
32
77
  - name: Stage the fleet's T3 policy
33
78
  ansible.builtin.import_playbook: t3-policy.yml
@@ -4,7 +4,8 @@
4
4
  "architectures": ["arm64", "x86_64"],
5
5
  "pmset": {
6
6
  "sleep": "0",
7
- "womp": "1"
7
+ "womp": "1",
8
+ "autorestart": "1"
8
9
  },
9
10
  "beszelDaemon": "dev.devfleet.beszel-agent",
10
11
  "disk": {
@@ -16,5 +17,9 @@
16
17
  "unhealthyChecks": 3,
17
18
  "maxRestartsPerHour": 3,
18
19
  "intervalSeconds": 60
20
+ },
21
+ "dockerDesktop": {
22
+ "settings": "Library/Group Containers/group.com.docker/settings-store.json",
23
+ "key": "AutoStart"
19
24
  }
20
25
  }
@@ -0,0 +1,33 @@
1
+ ---
2
+ # A worker's pinned SSH host key in the manager's known_hosts. fleet-sync
3
+ # connects with StrictHostKeyChecking=yes, so this is the only way a key is
4
+ # trusted: devfleet fleet-node-replace pins the key of a replacement worker
5
+ # (given by the operator, or by the seed that created it with
6
+ # fleet-node-seed) and devfleet fleet-node-retire forgets it. A key is never
7
+ # learned on first use. Variables: host (the name ssh connects to, with
8
+ # [host]:port for another port), pin (present or absent), key (with present:
9
+ # "TYPE BASE64") and known_hosts (the file).
10
+ - name: Pin or forget a worker's SSH host key on the manager
11
+ hosts: localhost
12
+ connection: local
13
+ gather_facts: false
14
+ tasks:
15
+ - name: Check the request
16
+ ansible.builtin.assert:
17
+ that:
18
+ - "host is match('^\\[?[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}(\\]:[0-9]{1,5})?$')"
19
+ - "pin in ['present', 'absent']"
20
+ - "pin == 'absent' or (key is match('^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}$'))"
21
+ - name: Pin the worker's host key
22
+ ansible.builtin.known_hosts:
23
+ name: '{{ host }}'
24
+ key: '{{ host }} {{ key }}'
25
+ path: '{{ known_hosts }}'
26
+ state: present
27
+ when: pin == 'present'
28
+ - name: Forget every key of the retired worker
29
+ ansible.builtin.known_hosts:
30
+ name: '{{ host }}'
31
+ path: '{{ known_hosts }}'
32
+ state: absent
33
+ when: pin == 'absent'
@@ -4,6 +4,8 @@
4
4
  connection: local
5
5
  become: true
6
6
  gather_facts: true
7
+ # fleet-repair re-applies this play alone for a failing beszel agent check.
8
+ tags: [monitoring]
7
9
  vars:
8
10
  beszel_lock: "{{ lookup('ansible.builtin.file', 'lock.json') | from_json }}"
9
11
  beszel_arch: "{{ {'x86_64': 'amd64', 'aarch64': 'arm64'}.get(ansible_facts.architecture, 'unsupported') }}"
@@ -8,24 +8,30 @@
8
8
  - policy.json
9
9
  pre_tasks:
10
10
  - ansible.builtin.import_tasks: tasks/release.yml
11
+ tags: [always]
11
12
  - name: Require an explicit non-root user
12
13
  ansible.builtin.assert:
13
14
  that:
14
15
  - development_user is match('^[a-z_][a-z0-9_-]{0,31}$')
15
16
  - development_user != 'root'
16
17
  fail_msg: Unsupported user; name the non-root developer account.
18
+ tags: [always]
17
19
  - name: Read the designated existing account
18
20
  ansible.builtin.getent:
19
21
  database: passwd
20
22
  key: '{{ development_user }}'
23
+ tags: [always]
21
24
  - name: Refuse a different user identity or home
22
25
  ansible.builtin.assert:
23
26
  that:
24
27
  - ansible_facts.getent_passwd[development_user][1] | int == development_uid | int
25
28
  - ansible_facts.getent_passwd[development_user][4] == development_home
29
+ tags: [always]
26
30
  - ansible.builtin.import_tasks: tasks/dpkg.yml
27
31
  # Memory and disk safety must land even when the disk is nearly full.
32
+ # fleet-repair re-applies them alone (--tags disk) for worker:capacity:*.
28
33
  - ansible.builtin.import_tasks: tasks/resources.yml
34
+ tags: [disk]
29
35
  - name: Inspect capacity before package and service changes
30
36
  ansible.builtin.command:
31
37
  argv:
@@ -4,6 +4,8 @@
4
4
  connection: local
5
5
  become: true
6
6
  gather_facts: false
7
+ # fleet-repair re-applies this play alone for worker:service-recovery.
8
+ tags: [recovery]
7
9
  tasks:
8
10
  - name: Verify the desktop unit runs before adding any recovery trigger
9
11
  ansible.builtin.command:
@@ -4,6 +4,8 @@
4
4
  connection: local
5
5
  become: true
6
6
  gather_facts: true
7
+ # fleet-repair re-applies this play alone for a failing tailscale:* check.
8
+ tags: [network]
7
9
  # The approved version and the signing key's digest: `tailscale` in policy.json.
8
10
  vars_files:
9
11
  - policy.json
@@ -0,0 +1,61 @@
1
+ # The first boot of a new worker, as cloud-init user data. devfleet
2
+ # fleet-node-seed fills each ${NAME} with a value of the worker it seeds:
3
+ # values from fleet.json, the manager's public key, a host key generated for
4
+ # the machine, the Tailscale version and key digest ansible/ubuntu/policy.json
5
+ # pins, and a short-lived single-use auth key that is deleted once the machine
6
+ # has joined. The sudoers fragment is the one ansible/ubuntu/authorize-sudo.sh
7
+ # writes: its path and marker are already on running machines.
8
+ hostname: '${HOSTNAME}'
9
+ users:
10
+ - name: '${USER}'
11
+ groups: [sudo]
12
+ shell: /bin/bash
13
+ lock_passwd: true
14
+ ssh_authorized_keys: ['${MANAGER_KEY}']
15
+ ssh_pwauth: false
16
+ ssh_deletekeys: true
17
+ ssh_genkeytypes: []
18
+ ssh_keys:
19
+ ed25519_private: '${HOST_KEY_PRIVATE}'
20
+ ed25519_public: '${HOST_KEY_PUBLIC}'
21
+ write_files:
22
+ - path: '/etc/sudoers.d/personal-fleet-${USER}'
23
+ owner: root:root
24
+ permissions: '0440'
25
+ content: "# Managed by personal Fleet: designated development user\n${USER} ALL=(ALL:ALL) NOPASSWD: ALL\n"
26
+ - path: /run/devfleet/tailnet-auth-key
27
+ owner: root:root
28
+ permissions: '0600'
29
+ content: "${AUTH_KEY}\n"
30
+ runcmd:
31
+ - [visudo, -cf, '/etc/sudoers.d/personal-fleet-${USER}']
32
+ - [
33
+ curl,
34
+ --fail,
35
+ --silent,
36
+ --show-error,
37
+ --location,
38
+ --output,
39
+ /usr/share/keyrings/tailscale-archive-keyring.gpg,
40
+ 'https://pkgs.tailscale.com/stable/ubuntu/${CODENAME}.noarmor.gpg',
41
+ ]
42
+ - [
43
+ sh,
44
+ -c,
45
+ "echo '${TAILSCALE_KEY_SHA256} /usr/share/keyrings/tailscale-archive-keyring.gpg' | sha256sum -c -",
46
+ ]
47
+ - [
48
+ sh,
49
+ -c,
50
+ "echo 'deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu ${CODENAME} main' > /etc/apt/sources.list.d/tailscale.list",
51
+ ]
52
+ - [apt-get, update]
53
+ - [apt-get, install, --yes, 'tailscale=${TAILSCALE_VERSION}']
54
+ - [
55
+ tailscale,
56
+ up,
57
+ '--auth-key=file:/run/devfleet/tailnet-auth-key',
58
+ '--hostname=${HOSTNAME}',
59
+ '--ssh=false',
60
+ ]
61
+ - [rm, -f, /run/devfleet/tailnet-auth-key]
@@ -59,6 +59,7 @@ Every failure code is in [the runbooks](runbooks.md).
59
59
  | `monitoring-backup-secret-key` | when its feature is declared | `DEVFLEET_SECRET_MONITORING_BACKUP_SECRET_KEY` | `~/.config/dev-fleet/secrets/monitoring-backup-secret-key` |
60
60
  | `notify-webhook-url` | when its feature is declared | `DEVFLEET_SECRET_NOTIFY_WEBHOOK_URL` | `~/.config/dev-fleet/secrets/notify-webhook-url` |
61
61
  | `notify-ntfy-url` | when its feature is declared | `DEVFLEET_SECRET_NOTIFY_NTFY_URL` | `~/.config/dev-fleet/secrets/notify-ntfy-url` |
62
+ | `tailnet-auth-key` | when its feature is declared | `DEVFLEET_SECRET_TAILNET_AUTH_KEY` | `~/.config/dev-fleet/secrets/tailnet-auth-key` |
62
63
 
63
64
  <!-- end: secrets -->
64
65
 
@@ -70,6 +71,7 @@ Every failure code is in [the runbooks](runbooks.md).
70
71
  | ----------------------------- | ------------------------------------------ |
71
72
  | `auth:github` | `src/github/checks.ts` |
72
73
  | `auth:vault` | `src/cli/fleet-sync-vault.ts` |
74
+ | `auth:vault-isolation` | `src/cli/fleet-sync-vault.ts` |
73
75
  | `worker:git-identity` | `src/git-identity.ts` |
74
76
  | `worker:git-push-auth` | `src/definition.ts`, `src/git-identity.ts` |
75
77
  | `worker:github-sync` | `src/worker-access-checks.ts` |
@@ -153,36 +153,37 @@ npx devfleet fleet-state --example > fleet.json
153
153
  Then replace every placeholder. Each field, from
154
154
  [`schema/fleet.schema.json`](../schema/fleet.schema.json):
155
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"] }`: a release of the [support matrix](support-matrix.md). |
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
- | `github` (optional) | Who the manager signs in to GitHub as, per-node tokens, required scopes; see [GitHub](github.md). |
183
- | `worker.vaultUser` | The Vault user each worker's token is issued for. |
184
- | `worker.toolchain`, `agentProfile` | `"mise.lock"`, `"proxy"` (fixed). |
185
- | `worker.unattendedSudo`, `persistentT3`, `neverSleep` | `true` (fixed policy). |
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 reachable worker is the canary: a new revision must pass there before the others get it. |
168
+ | `canary` | Optional: the id of the worker to prefer as canary. When it is unreachable, the next reachable worker is used. |
169
+ | `workers[].ssh` | The worker's alias in the manager's `~/.ssh/config`. |
170
+ | `workers[].os` | `"linux"` |
171
+ | `workers[].network.tailscaleName` | The worker's MagicDNS name (from `tailscale status --json` → `.Peer[].DNSName` on the manager). |
172
+ | `workers[].network.sshPort` | `22` unless the worker's sshd listens elsewhere. |
173
+ | `workers[].network.user` | The worker account (`ssh WORKER_ALIAS id -un`). |
174
+ | `workers[].network.t3Port` | `3773` |
175
+ | `services.proxy.endpoint` | The public HTTPS origin of the CLI proxy, e.g. `https://cli-proxy.example.com`. |
176
+ | `services.monitoring.hubPort` | `8443` |
177
+ | `services.monitoring.publicUrl` | The public HTTPS origin of the monitoring dashboard. |
178
+ | `services.monitoring.managerAccount` | The email of the monitoring account the manager enrolls machines with. |
179
+ | `services.monitoring.viewerAccount` | The email of the read-only account visitors are signed in as. |
180
+ | `ubuntu` | `{ "release": "26.04", "architectures": ["amd64", "arm64"] }`: a release of the [support matrix](support-matrix.md). |
181
+ | `worker.requiredCapabilities` | Normally all of `github`, `vault`, `t3`, `toolchain`, `workstation`, `worker`. |
182
+ | `worker.githubLogin` | The GitHub login every worker must be signed in as. |
183
+ | `github` (optional) | Who the manager signs in to GitHub as, per-node tokens, required scopes; see [GitHub](github.md). |
184
+ | `worker.vaultUser` | The Vault user each worker's token is issued for. |
185
+ | `worker.toolchain`, `agentProfile` | `"mise.lock"`, `"proxy"` (fixed). |
186
+ | `worker.unattendedSudo`, `persistentT3`, `neverSleep` | `true` (fixed policy). |
186
187
 
187
188
  Use the same values in every place they repeat. Validate before going on:
188
189
 
@@ -363,7 +364,7 @@ Read `summary` and `remedy.action` of the failing check. The common ones:
363
364
  | `ROOT_DEVELOPMENT_USER_REJECTED` | The worker's SSH user is root. Use a normal account with sudo. |
364
365
  | `BOOTSTRAP_DISK_CAPACITY_REQUIRED` | Less than 2 GiB free (or too few inodes) where the fleet keeps its files on the worker. |
365
366
  | `FLEET_REMOTE_LOCKED` | Another operation is running on that worker. Wait and run again. |
366
- | `FLEET_PROMOTION_BLOCKED` | The canary (first worker) failed, so the new revision went no further. Fix the canary first. |
367
+ | `FLEET_PROMOTION_BLOCKED` | The canary (the report names it under `canary`) failed, so the new revision went no further. Fix the canary first. |
367
368
  | `VAULT_LOGIN_REQUIRED` | The manager is not logged in to Vault. The human logs in; the next sync issues worker tokens. |
368
369
  | `PROVIDER_UNATTENDED_CREDENTIAL_REQUIRED` | A coding agent has no credential a background service can use. The human signs in on the proxy. |
369
370
  | `BESZEL_ACCOUNT_REQUIRED` | The monitoring hub has no account yet. Follow the remedy; other workers are not blocked. |
@@ -382,10 +383,8 @@ and codes; leave out everything private.
382
383
  - **Upgrade the fleet.** `npm install --save-dev --save-exact @crvouga/dev-fleet@latest`,
383
384
  commit, `npx devfleet fleet-sync`. The canary proves the new revision first.
384
385
  Install an older version and sync to roll back.
385
- - **Replace the manager.** On the new Mac: clone the project, `npm ci`, put
386
- the bucket credentials in the environment, `npx devfleet fleet-sync`. The
387
- roster and the approved revision come back from the bucket. See
388
- [state](state.md) for what the bucket does not hold yet.
386
+ - **Replace the manager.** Follow
387
+ [replacing the manager](manager-recovery.md).
389
388
 
390
389
  ## Moving from a pinned commit and a wrapper script
391
390
 
package/docs/declared.md CHANGED
@@ -12,29 +12,29 @@ may only shrink.
12
12
 
13
13
  ## Declared
14
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
- | Worker time service | `ansible/ubuntu/tasks/services.yml`, offsets in `clock` in `ansible/ubuntu/policy.json` | The commit | `fleet-sync` | `worker:clock` |
35
- | The manager's container watchdog | `ansible/monitoring/container-watchdog.py`, `containerWatchdog` in `ansible/macos/policy.json` | The commit | `fleet-sync` (`hub-darwin.yml`) | `manager:container-watchdog` |
36
- | Docker Desktop on the manager | `docker_desktop_*` under `[vars]` in `mise.toml` | Version and build | You | `manager:docker` (warns) |
37
- | CI | `.github/workflows/` | Each action at a commit; tools from `mise.lock` | GitHub | `features/rules/` |
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`, on every `fleet-sync`, after one grant (`manager-privilege.yml`) | `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
+ | Worker time service | `ansible/ubuntu/tasks/services.yml`, offsets in `clock` in `ansible/ubuntu/policy.json` | The commit | `fleet-sync` | `worker:clock` |
35
+ | The manager's container watchdog | `ansible/monitoring/container-watchdog.py`, `containerWatchdog` in `ansible/macos/policy.json` | The commit | `fleet-sync` (`hub-darwin.yml`) | `manager:container-watchdog` |
36
+ | Docker Desktop on the manager | `docker_desktop_*` under `[vars]` in `mise.toml` | Version and build | You | `manager:docker` (warns) |
37
+ | CI | `.github/workflows/` | Each action at a commit; tools from `mise.lock` | GitHub | `features/rules/` |
38
38
 
39
39
  A value lives in a policy file when something besides the playbook reads it,
40
40
  so the check and the playbook cannot disagree. A value only a playbook uses
@@ -89,4 +89,4 @@ would settle and the current tools do not is the rest of a worker's packages.
89
89
  | An Ubuntu package | `packages` in `ansible/ubuntu/policy.json`, and `snapshot` if needed | The VM test in `ansible/ubuntu/tests` |
90
90
  | Ansible on the manager | `ansible/pyproject.toml` | `uv lock` in `ansible/` |
91
91
  | Beszel | `ansible/monitoring/lock.json`, `compose.yml`, the agent unit | `bun test features/rules` |
92
- | A manager power setting | `pmset` in `ansible/macos/policy.json` | Run `manager.yml` once on the manager |
92
+ | A manager power setting | `pmset` in `ansible/macos/policy.json` | The next `fleet-sync` applies it |