@crvouga/dev-fleet 0.8.0 → 0.9.1

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 (205) hide show
  1. package/README.md +65 -9
  2. package/agents/audit.md +18 -18
  3. package/ansible/agents/agents.yml +134 -0
  4. package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
  5. package/ansible/agents/templates/claude.settings.json.j2 +3 -0
  6. package/ansible/agents/templates/codex.config.toml.j2 +11 -0
  7. package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
  8. package/ansible/agents/templates/grok.config.toml.j2 +19 -0
  9. package/ansible/agents/templates/opencode.json.j2 +24 -0
  10. package/ansible/agents/templates/receipt.json.j2 +10 -0
  11. package/ansible/ansible.cfg +14 -0
  12. package/ansible/github/git.yml +82 -0
  13. package/ansible/macos/manager-privilege.yml +55 -9
  14. package/ansible/macos/manager.yml +154 -7
  15. package/ansible/macos/policy.json +13 -0
  16. package/ansible/macos/t3-policy.yml +5 -3
  17. package/ansible/macos/vault-renew.yml +85 -0
  18. package/ansible/monitoring/agent-darwin.yml +3 -0
  19. package/ansible/monitoring/agent.yml +2 -0
  20. package/ansible/monitoring/hub-darwin.yml +3 -0
  21. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  22. package/ansible/net/remote-probe.sh +4 -1
  23. package/ansible/platforms.json +2 -2
  24. package/ansible/pyproject.toml +5 -0
  25. package/ansible/ubuntu/baseline.yml +39 -13
  26. package/ansible/ubuntu/disk-gc.py +6 -2
  27. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  28. package/ansible/ubuntu/policy.json +125 -6
  29. package/ansible/ubuntu/reboot.yml +89 -0
  30. package/ansible/ubuntu/recovery.yml +13 -3
  31. package/ansible/ubuntu/t3/policy.json +3 -0
  32. package/ansible/ubuntu/t3-desktop.yml +23 -2
  33. package/ansible/ubuntu/t3-headless.yml +128 -0
  34. package/ansible/ubuntu/t3-health.sh +24 -10
  35. package/ansible/ubuntu/t3-idle.py +37 -2
  36. package/ansible/ubuntu/t3-policy.yml +4 -1
  37. package/ansible/ubuntu/t3-route.yml +1 -0
  38. package/ansible/ubuntu/tailscale.yml +54 -8
  39. package/ansible/ubuntu/tasks/browser.yml +3 -0
  40. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  41. package/ansible/ubuntu/tasks/docker.yml +53 -6
  42. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  43. package/ansible/ubuntu/tasks/features.yml +22 -0
  44. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  45. package/ansible/ubuntu/tasks/install.yml +2 -1
  46. package/ansible/ubuntu/tasks/packages.yml +4 -2
  47. package/ansible/ubuntu/tasks/release.yml +6 -0
  48. package/ansible/ubuntu/tasks/resources.yml +6 -4
  49. package/ansible/ubuntu/tasks/services.yml +2 -2
  50. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  51. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  52. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  53. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  54. package/ansible/ubuntu/tasks/user.yml +1 -0
  55. package/ansible/ubuntu/worker-account.yml +117 -0
  56. package/ansible/uv.lock +265 -0
  57. package/bin/devfleet.mjs +42 -4
  58. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  59. package/bootstrap/global-config.ts +29 -3
  60. package/bootstrap/package-smoke.ts +127 -0
  61. package/bootstrap/pins.ts +189 -0
  62. package/bootstrap/release-notes.ts +88 -0
  63. package/bootstrap/release-version.ts +41 -7
  64. package/bootstrap/worker-features.ts +114 -0
  65. package/bootstrap/worker-seed.yaml +7 -3
  66. package/bootstrap/worker-tools.ts +39 -9
  67. package/cliproxy/README.md +1 -1
  68. package/cliproxy/docs/health-workflow.example.yml +20 -143
  69. package/docs/authentication.md +19 -11
  70. package/docs/consumer-setup.md +42 -27
  71. package/docs/convergence.md +67 -0
  72. package/docs/declared.md +61 -34
  73. package/docs/machine-setup.md +73 -7
  74. package/docs/manager-setup.md +10 -3
  75. package/docs/operations.md +64 -9
  76. package/docs/releasing.md +49 -1
  77. package/docs/runbooks.md +98 -7
  78. package/docs/state.md +23 -11
  79. package/docs/support-matrix.md +40 -4
  80. package/docs/topology.md +67 -6
  81. package/docs/ubuntu-sync.md +78 -28
  82. package/docs/worker-contract.md +122 -19
  83. package/docs/worker-requirements.md +107 -0
  84. package/examples/fleet.json +1 -0
  85. package/examples/workers-from-infrastructure/main.tf +132 -0
  86. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  87. package/llms.txt +3 -1
  88. package/mise.lock +29 -0
  89. package/mise.toml +5 -3
  90. package/package.json +2 -2
  91. package/schema/check-ids.json +1967 -170
  92. package/schema/check-ids.schema.json +34 -0
  93. package/schema/fleet.schema.json +327 -8
  94. package/schema/output/check.schema.json +1 -1
  95. package/schema/output/fleet-state-definition.schema.json +14 -0
  96. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  97. package/schema/output/fleet-sync.schema.json +18 -0
  98. package/schema/output/report.schema.json +3 -2
  99. package/schema/worker-record.schema.json +76 -0
  100. package/src/agents/converge.ts +140 -24
  101. package/src/agents/files.ts +2 -32
  102. package/src/agents/native-verify.ts +34 -3
  103. package/src/agents/secrets.ts +39 -4
  104. package/src/check-owners.ts +61 -2
  105. package/src/check-registry.ts +630 -0
  106. package/src/cli/agent-guide.ts +3 -3
  107. package/src/cli/cli.ts +4 -0
  108. package/src/cli/fleet-manifest.ts +9 -3
  109. package/src/cli/fleet-node-lifecycle.ts +313 -64
  110. package/src/cli/fleet-node-park.ts +238 -0
  111. package/src/cli/fleet-node-seed.ts +51 -35
  112. package/src/cli/fleet-state.ts +2 -1
  113. package/src/cli/fleet-sync-checkout.ts +7 -0
  114. package/src/cli/fleet-sync-enrollment.ts +193 -32
  115. package/src/cli/fleet-sync-forward.ts +4 -0
  116. package/src/cli/fleet-sync-manager.ts +103 -33
  117. package/src/cli/fleet-sync-output.ts +31 -11
  118. package/src/cli/fleet-sync-plan.ts +65 -9
  119. package/src/cli/fleet-sync-records.ts +113 -0
  120. package/src/cli/fleet-sync-remote.ts +151 -9
  121. package/src/cli/fleet-sync-schedule.ts +14 -4
  122. package/src/cli/fleet-sync-state.ts +28 -16
  123. package/src/cli/fleet-sync-vault.ts +77 -0
  124. package/src/cli/fleet-sync-worker.ts +214 -0
  125. package/src/cli/fleet-sync.ts +150 -19
  126. package/src/cli/fleet-validate.ts +59 -21
  127. package/src/cli/fleet-watch.ts +7 -1
  128. package/src/cli/fleet.ts +87 -5
  129. package/src/cli/managed-health.ts +25 -1
  130. package/src/cli/register-fleet.ts +115 -4
  131. package/src/cli/registry.ts +14 -0
  132. package/src/cli/tailnet.ts +43 -10
  133. package/src/cli/ui-data.ts +3 -1
  134. package/src/cli/worker-usage-report.ts +63 -0
  135. package/src/clock.ts +42 -0
  136. package/src/definition.ts +332 -9
  137. package/src/diagnostics.ts +5 -2
  138. package/src/exposure.ts +102 -0
  139. package/src/fleet-config.ts +3 -1
  140. package/src/git-identity.ts +76 -3
  141. package/src/ide/ide.ts +5 -0
  142. package/src/ide/t3.ts +12 -2
  143. package/src/input-hashes.ts +6 -0
  144. package/src/machine/machine-class.ts +59 -0
  145. package/src/machine/platform.ts +90 -11
  146. package/src/machine/state.ts +37 -15
  147. package/src/machine/support-matrix.ts +11 -9
  148. package/src/machine-health.ts +28 -5
  149. package/src/manager-health.ts +148 -2
  150. package/src/manager-sync.ts +65 -4
  151. package/src/monitoring/beszel.ts +8 -8
  152. package/src/monitoring/enroll.ts +8 -1
  153. package/src/monitoring/hub-access.ts +10 -4
  154. package/src/monitoring/systems.ts +138 -0
  155. package/src/network-definition.ts +13 -4
  156. package/src/node-converge-linux.ts +136 -27
  157. package/src/node-converge.ts +13 -3
  158. package/src/platform/ansible-receipt.ts +17 -2
  159. package/src/platform/ansible.ts +40 -4
  160. package/src/platform/manager-host.ts +25 -8
  161. package/src/platform/resource-verify.ts +18 -7
  162. package/src/platform/t3-policy.ts +24 -10
  163. package/src/platform/t3-service.ts +109 -34
  164. package/src/platform/ubuntu-release.ts +24 -0
  165. package/src/platform/ubuntu-verify.ts +31 -3
  166. package/src/platform/ubuntu-worker.ts +58 -0
  167. package/src/platform/worker-platform.ts +72 -0
  168. package/src/protocol.ts +1 -1
  169. package/src/public-reachability.ts +186 -0
  170. package/src/remote-bootstrap.ts +50 -12
  171. package/src/remote-operation.ts +18 -4
  172. package/src/repair-plan.ts +23 -12
  173. package/src/report.ts +2 -1
  174. package/src/schedule-check.ts +21 -2
  175. package/src/secrets.ts +4 -0
  176. package/src/service-auth.ts +55 -48
  177. package/src/ssh-connection.ts +141 -0
  178. package/src/ssh.ts +63 -24
  179. package/src/state/store.ts +4 -2
  180. package/src/state/sync.ts +35 -6
  181. package/src/state/workers.ts +140 -0
  182. package/src/t3/pairing.ts +59 -0
  183. package/src/t3/peer-link.ts +333 -0
  184. package/src/t3/version.ts +1 -1
  185. package/src/tailnet-cleanup.ts +185 -0
  186. package/src/tailnet-control.ts +346 -0
  187. package/src/tailscale.ts +1 -1
  188. package/src/toolchain-checks.ts +11 -1
  189. package/src/types.ts +4 -2
  190. package/src/worker-account.ts +63 -0
  191. package/src/worker-activity.ts +303 -0
  192. package/src/worker-capability-checks.ts +55 -16
  193. package/src/worker-capacity.ts +31 -0
  194. package/src/worker-exposure.ts +388 -0
  195. package/src/worker-features.ts +201 -0
  196. package/src/worker-host-checks.ts +71 -20
  197. package/src/worker-manifest.ts +27 -4
  198. package/src/worker-parking.ts +70 -0
  199. package/src/worker-policy.ts +17 -1
  200. package/src/worker-readiness.ts +15 -3
  201. package/src/worker-reboot.ts +129 -0
  202. package/src/worker-records.ts +305 -0
  203. package/src/worker-seed.ts +16 -11
  204. package/src/worker-sizing.ts +76 -0
  205. package/src/cli/schedule-logs.ts +0 -36
@@ -1,11 +1,17 @@
1
1
  ---
2
+ # The manager's own policy, applied by fleet-sync on every applying run:
3
+ # check mode first, then for real, with a receipt (macos-manager) that a full
4
+ # re-apply replaces at least once a day. It never asks for a password and no
5
+ # play becomes root: what needs root runs through the narrow grant
6
+ # manager-privilege.yml installs once (with the T3 policy and the log
7
+ # rotation), and without the grant it is skipped here and reported by its
8
+ # check.
9
+ #
2
10
  # Manager power policy. Workers depend on the manager's singleton services
3
11
  # (CLI proxy, Beszel hub), so the manager never sleeps on AC power and starts
4
12
  # again by itself when power returns (autorestart). Sleep on battery is left
5
13
  # 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.
14
+ # measures the same object as manager:power.
9
15
  - name: Keep the fleet manager awake on AC power
10
16
  hosts: localhost
11
17
  connection: local
@@ -17,7 +23,32 @@
17
23
  ansible.builtin.command:
18
24
  argv: [/usr/bin/pmset, -g, custom]
19
25
  changed_when: false
26
+ check_mode: false
20
27
  register: pmset_custom
28
+ # Lists, never runs: whether `pmset -c` may run without a password.
29
+ - name: Inspect the manager's power grant
30
+ ansible.builtin.command:
31
+ argv: [/usr/bin/sudo, -n, -l, /usr/bin/pmset, -c, sleep, '0']
32
+ changed_when: false
33
+ check_mode: false
34
+ failed_when: false
35
+ register: pmset_grant
36
+ - name: Report that the power settings wait for the grant
37
+ ansible.builtin.debug:
38
+ msg: 'The power grant is not in place; manager:power says how to give it once.'
39
+ when: pmset_grant.rc != 0
40
+ # pmset accepts a setting this Mac does not have and keeps nothing (a
41
+ # virtual Mac has no Wake-on-LAN, so no womp): applying it again on every
42
+ # run would never converge. It is reported instead of applied.
43
+ - name: Report the declared AC power settings this Mac does not have
44
+ ansible.builtin.debug:
45
+ msg: 'pmset on this Mac has no AC setting {{ item.key }}; it is not applied.'
46
+ loop: '{{ pmset | dict2items }}'
47
+ loop_control:
48
+ label: '{{ item.key }}'
49
+ vars:
50
+ ac_section: "{{ pmset_custom.stdout.split('AC Power:') | last }}"
51
+ when: ac_section is not search('^\\s*' ~ item.key ~ '\\s', multiline=True)
21
52
  - name: Apply the declared AC power settings
22
53
  ansible.builtin.command:
23
54
  argv:
@@ -35,7 +66,9 @@
35
66
  vars:
36
67
  ac_section: "{{ pmset_custom.stdout.split('AC Power:') | last }}"
37
68
  observed: "{{ ac_section | regex_findall('^\\s*' ~ item.key ~ '\\s+(\\S+)', multiline=True) | first | default('') }}"
38
- when: observed != item.value
69
+ when:
70
+ - pmset_grant.rc == 0
71
+ - observed not in ['', item.value]
39
72
  changed_when: true
40
73
 
41
74
  # Docker Desktop hosts the CLI proxy and the monitoring hub, so it starts at
@@ -72,7 +105,121 @@
72
105
  # Docker Desktop writes the file the first time it runs.
73
106
  when:
74
107
  - docker_settings_file.stat.exists
75
- - (current[dockerDesktop.key] | default(false)) != true
108
+ - not (current[dockerDesktop.key] | default(false) | bool)
109
+
110
+ # fleet-watch compares the fleet's health with its last run, notifies on a
111
+ # change and stores the heartbeat the off-machine workflow
112
+ # (.github/workflows/fleet-heartbeat.yml) reads. It runs from the checkout
113
+ # fleet-sync last applied (fleet/current) with that checkout's locked Bun, on
114
+ # the interval `manager.schedule.watchMinutes` in fleet.json sets
115
+ # (fleet_watch_minutes), or else policy.json's. manager:watch measures it.
116
+ - name: Watch the fleet on a schedule
117
+ hosts: localhost
118
+ connection: local
119
+ become: false
120
+ gather_facts: true
121
+ gather_subset: ['!all']
122
+ vars_files:
123
+ - policy.json
124
+ vars:
125
+ watch_home: '{{ ansible_facts.env.HOME }}'
126
+ watch_label: '{{ fleetWatch.label }}'
127
+ watch_minutes: '{{ fleet_watch_minutes | default(fleetWatch.intervalMinutes) | int }}'
128
+ watch_logs: '{{ watch_home }}/{{ jobLogs.directory }}'
129
+ watch_plist: '{{ watch_home }}/Library/LaunchAgents/{{ watch_label }}.plist'
130
+ watch_domain: 'gui/{{ ansible_facts.user_uid }}'
131
+ tasks:
132
+ - name: Require an interval the schema allows
133
+ ansible.builtin.assert:
134
+ that:
135
+ - watch_minutes | int >= 5
136
+ - watch_minutes | int <= 1440
137
+ fail_msg: MANAGER_WATCH_INTERVAL_INVALID; manager.schedule.watchMinutes is from 5 to 1440.
138
+ - name: Create the directories of the watch job
139
+ ansible.builtin.file:
140
+ path: '{{ item }}'
141
+ state: directory
142
+ mode: '0700'
143
+ loop:
144
+ - '{{ watch_logs }}'
145
+ - '{{ watch_home }}/Library/LaunchAgents'
146
+ - name: Install the fleet-watch launch agent
147
+ ansible.builtin.copy:
148
+ dest: '{{ watch_plist }}'
149
+ mode: '0644'
150
+ content: |
151
+ <?xml version="1.0" encoding="UTF-8"?>
152
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
153
+ <!-- Managed by dev-fleet: fleet-watch -->
154
+ <plist version="1.0"><dict>
155
+ <key>Label</key><string>{{ watch_label }}</string>
156
+ <key>ProgramArguments</key><array>
157
+ <string>{{ watch_home }}/.local/bin/mise</string>
158
+ <string>exec</string>
159
+ <string>--locked</string>
160
+ <string>bun</string>
161
+ <string>--</string>
162
+ <string>bun</string>
163
+ <string>src/cli/cli.ts</string>
164
+ <string>fleet-watch</string>
165
+ <string>--json</string>
166
+ </array>
167
+ <key>WorkingDirectory</key><string>{{ watch_home }}/.local/share/personal/fleet/current</string>
168
+ <key>EnvironmentVariables</key><dict>
169
+ <key>PATH</key><string>{{ watch_home }}/.local/share/mise/shims:{{ watch_home }}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
170
+ <key>DEVFLEET_TRIGGER</key><string>watch</string>
171
+ </dict>
172
+ <key>StartInterval</key><integer>{{ watch_minutes | int * 60 }}</integer>
173
+ <key>ProcessType</key><string>Background</string>
174
+ <key>LowPriorityIO</key><true/>
175
+ <key>Nice</key><integer>10</integer>
176
+ <key>Umask</key><integer>63</integer>
177
+ <key>StandardOutPath</key><string>{{ watch_logs }}/{{ watch_label }}.out.log</string>
178
+ <key>StandardErrorPath</key><string>{{ watch_logs }}/{{ watch_label }}.err.log</string>
179
+ </dict></plist>
180
+ register: watch_agent
181
+ - name: Inspect whether the watch job is loaded
182
+ ansible.builtin.command:
183
+ argv: [/bin/launchctl, print, '{{ watch_domain }}/{{ watch_label }}']
184
+ changed_when: false
185
+ check_mode: false
186
+ failed_when: false
187
+ register: watch_loaded
188
+ # launchd caches a loaded definition: a changed plist needs a reload.
189
+ - name: Unload a stale watch job definition
190
+ ansible.builtin.command:
191
+ argv: [/bin/launchctl, bootout, '{{ watch_domain }}/{{ watch_label }}']
192
+ when: watch_agent.changed and watch_loaded.rc == 0
193
+ failed_when: false
194
+ changed_when: true
195
+ - name: Load the watch job
196
+ ansible.builtin.command:
197
+ argv:
198
+ [/bin/launchctl, bootstrap, '{{ watch_domain }}', '{{ watch_plist }}']
199
+ when: watch_agent.changed or watch_loaded.rc != 0
200
+ changed_when: true
76
201
 
77
- - name: Stage the fleet's T3 policy
78
- ansible.builtin.import_playbook: t3-policy.yml
202
+ # A skewed clock breaks TLS, token validation and Vault logins with errors
203
+ # that never mention time, and every worker's offset is measured against the
204
+ # manager's. macOS keeps its own time service; this asserts it is on.
205
+ # Reading it needs root, through the grant manager-privilege.yml installs;
206
+ # without the grant it is not asserted here, and manager:clock says so.
207
+ - name: Keep the manager's clock on network time
208
+ hosts: localhost
209
+ connection: local
210
+ become: false
211
+ gather_facts: false
212
+ tasks:
213
+ - name: Read whether network time is on
214
+ ansible.builtin.command:
215
+ argv: [/usr/bin/sudo, -n, /usr/sbin/systemsetup, -getusingnetworktime]
216
+ changed_when: false
217
+ check_mode: false
218
+ failed_when: false
219
+ register: network_time
220
+ - name: Require network time
221
+ ansible.builtin.assert:
222
+ that:
223
+ - "network_time.stdout is search('Network Time:\\s*On')"
224
+ fail_msg: MANAGER_CLOCK_NOT_SYNCHRONISED; turn on Set time and date automatically in System Settings > General > Date & Time.
225
+ when: network_time.rc == 0
@@ -21,5 +21,18 @@
21
21
  "dockerDesktop": {
22
22
  "settings": "Library/Group Containers/group.com.docker/settings-store.json",
23
23
  "key": "AutoStart"
24
+ },
25
+ "fleetSync": {
26
+ "label": "personal-fleet-sync",
27
+ "intervalMinutes": 30
28
+ },
29
+ "fleetWatch": {
30
+ "label": "dev.devfleet.fleet-watch",
31
+ "intervalMinutes": 15
32
+ },
33
+ "jobLogs": {
34
+ "directory": ".local/share/personal/fleet/manager/logs",
35
+ "maxKiB": 1024,
36
+ "keep": 3
24
37
  }
25
38
  }
@@ -1,10 +1,12 @@
1
1
  ---
2
2
  # T3 Code on the manager reads the same administrator policy as on every
3
- # worker (ansible/ubuntu/t3/policy.json): updates off, so the manager's T3
4
- # changes only when the fleet's pin does. Provider instances are reconciled
3
+ # worker (ansible/ubuntu/t3/policy.json): linked environments on, so the
4
+ # manager pairs with a replaced worker's T3 by itself, and updates off, so
5
+ # the manager's T3 changes only when the fleet's pin does. Provider instances are reconciled
5
6
  # by fleet-sync on every run and stay unlocked here: locking them needs an
6
7
  # administrator for each change of the account roster. fleet-sync measures
7
- # the result as t3:updates. Needs an administrator: ansible-playbook -K manager.yml
8
+ # the result as t3:updates. Needs an administrator, once, through
9
+ # manager-privilege.yml: ansible-playbook -K manager-privilege.yml -e user=$(id -un)
8
10
  - name: Stage the fleet's T3 policy on the manager
9
11
  hosts: localhost
10
12
  connection: local
@@ -0,0 +1,85 @@
1
+ ---
2
+ # The manager's own Vault session renews itself, as every worker's does
3
+ # (personal-worker-auth): without it the session lapses while nobody runs
4
+ # fleet-sync, and the manager can no longer read the secrets its services and
5
+ # its agents use. The job only renews; a lapsed session needs one login by a
6
+ # person. Measured as manager:vault-renewal. fleet-sync passes vault_address
7
+ # (the fleet configuration's secrets block) and mise (its absolute path).
8
+ - name: Keep the manager's Vault session renewed
9
+ hosts: localhost
10
+ connection: local
11
+ become: false
12
+ gather_facts: true
13
+ vars:
14
+ renew_label: dev.devfleet.vault-renew
15
+ renew_plist: '{{ ansible_facts.env.HOME }}/Library/LaunchAgents/{{ renew_label }}.plist'
16
+ renew_domain: 'gui/{{ ansible_facts.user_uid }}'
17
+ tasks:
18
+ - name: Require the macOS manager and an HTTPS Vault
19
+ ansible.builtin.assert:
20
+ that:
21
+ - ansible_facts.os_family == 'Darwin'
22
+ - vault_address is match('^https://[A-Za-z0-9.-]+$')
23
+ - mise is match('^/')
24
+ fail_msg: VAULT_RENEWAL_NOT_MANAGER; the renewal job is for the macOS manager's own session.
25
+ - name: Create the launch agents directory
26
+ ansible.builtin.file:
27
+ path: '{{ ansible_facts.env.HOME }}/Library/LaunchAgents'
28
+ state: directory
29
+ mode: '0700'
30
+ # Every ten minutes, like the workers, and at load: a renewal never
31
+ # outlives the token, so a missed run only shortens the margin. Output is
32
+ # dropped (the lease, or why it failed): manager:vault-renewal measures the
33
+ # session itself.
34
+ - name: Install the renewal job's launch agent
35
+ ansible.builtin.copy:
36
+ dest: '{{ renew_plist }}'
37
+ mode: '0644'
38
+ content: |
39
+ <?xml version="1.0" encoding="UTF-8"?>
40
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
41
+ <!-- Managed by personal Fleet: Vault session renewal -->
42
+ <plist version="1.0"><dict>
43
+ <key>Label</key><string>{{ renew_label }}</string>
44
+ <key>ProgramArguments</key><array>
45
+ <string>{{ mise }}</string>
46
+ <string>exec</string>
47
+ <string>--</string>
48
+ <string>vault</string>
49
+ <string>token</string>
50
+ <string>renew</string>
51
+ <string>-address={{ vault_address }}</string>
52
+ <string>-increment=24h</string>
53
+ </array>
54
+ <key>WorkingDirectory</key><string>{{ ansible_facts.env.HOME }}</string>
55
+ <key>EnvironmentVariables</key><dict>
56
+ <key>PATH</key><string>{{ mise | dirname }}:/usr/local/bin:/usr/bin:/bin</string>
57
+ </dict>
58
+ <key>RunAtLoad</key><true/>
59
+ <key>StartInterval</key><integer>600</integer>
60
+ <key>ProcessType</key><string>Background</string>
61
+ <key>Umask</key><integer>63</integer>
62
+ <key>StandardOutPath</key><string>/dev/null</string>
63
+ <key>StandardErrorPath</key><string>/dev/null</string>
64
+ </dict></plist>
65
+ register: renew_agent
66
+ - name: Inspect whether the renewal job is loaded
67
+ ansible.builtin.command:
68
+ argv: [/bin/launchctl, print, '{{ renew_domain }}/{{ renew_label }}']
69
+ changed_when: false
70
+ check_mode: false
71
+ failed_when: false
72
+ register: renew_loaded
73
+ # launchd caches a loaded definition: a changed plist needs a reload.
74
+ - name: Unload a stale renewal job definition
75
+ ansible.builtin.command:
76
+ argv: [/bin/launchctl, bootout, '{{ renew_domain }}/{{ renew_label }}']
77
+ when: renew_agent.changed and renew_loaded.rc == 0
78
+ failed_when: false
79
+ changed_when: true
80
+ - name: Load the renewal job
81
+ ansible.builtin.command:
82
+ argv:
83
+ [/bin/launchctl, bootstrap, '{{ renew_domain }}', '{{ renew_plist }}']
84
+ when: renew_agent.changed or renew_loaded.rc != 0
85
+ changed_when: true
@@ -137,6 +137,7 @@
137
137
  ansible.builtin.command:
138
138
  argv: [/bin/launchctl, print, '{{ beszel_domain }}/{{ beszelDaemon }}']
139
139
  changed_when: false
140
+ check_mode: false
140
141
  failed_when: false
141
142
  register: agent_loaded
142
143
  - name: Load the agent at login and now
@@ -156,6 +157,8 @@
156
157
  ansible.builtin.command:
157
158
  argv: [/bin/launchctl, print, '{{ beszel_domain }}/{{ beszelDaemon }}']
158
159
  changed_when: false
160
+ # Verifies a change a dry run does not make.
161
+ when: not ansible_check_mode
159
162
  register: agent_state
160
163
  retries: 5
161
164
  delay: 2
@@ -166,6 +166,8 @@
166
166
  ansible.builtin.command:
167
167
  argv: [systemctl, is-active, beszel-agent.service]
168
168
  changed_when: false
169
+ # Verifies a change a dry run does not make.
170
+ when: not ansible_check_mode
169
171
  handlers:
170
172
  - name: Restart agent
171
173
  ansible.builtin.systemd_service:
@@ -39,6 +39,7 @@
39
39
  ansible.builtin.command:
40
40
  argv: [docker, compose, version]
41
41
  changed_when: false
42
+ check_mode: false
42
43
  - name: Inspect an existing hub image before any upgrade
43
44
  ansible.builtin.command:
44
45
  argv:
@@ -50,6 +51,7 @@
50
51
  personal-beszel-hub-1,
51
52
  ]
52
53
  changed_when: false
54
+ check_mode: false
53
55
  failed_when: false
54
56
  register: existing_hub
55
57
  - name: Require explicit maintenance for a different existing image
@@ -221,6 +223,7 @@
221
223
  argv:
222
224
  [/bin/launchctl, print, '{{ watchdog_domain }}/{{ watchdog_label }}']
223
225
  changed_when: false
226
+ check_mode: false
224
227
  failed_when: false
225
228
  register: watchdog_loaded
226
229
  # launchd caches a loaded definition: a changed plist needs a reload.
@@ -29,6 +29,7 @@
29
29
  argv: ['{{ beszel_tailscale_cli }}', serve, status, --json]
30
30
  register: beszel_serve_status
31
31
  changed_when: false
32
+ check_mode: false
32
33
  - name: Publish the hub on the tailnet HTTPS port as the signed-in user
33
34
  ansible.builtin.command:
34
35
  argv: '{{ beszel_serve }}'
@@ -12,7 +12,10 @@ else
12
12
  df -Pi "$HOME" | awk 'NR==2 && $2>0{print "inodefree=" int($4*100/$2)}'
13
13
  # Clock synchronisation, as systemd sees it: 1 or 0.
14
14
  timedatectl show -p NTPSynchronized -p NTP 2>/dev/null | awk -F= '{v=($2=="yes")?1:0} $1=="NTP"{print "ntp=" v} $1=="NTPSynchronized"{print "ntpsync=" v}'
15
- awk '/MemTotal/{t=$2} /MemAvailable/{a=$2} /SwapTotal/{st=$2} /SwapFree/{sf=$2} END{print "memfree=" int(a*100/t); if (st>0) print "swap=" int((st-sf)*100/st); else print "swap=0"}' /proc/meminfo
15
+ # Agent sessions running now: the scopes T3 starts its agents in.
16
+ echo "agentsessions=$(systemctl --user list-units --type=scope --state=active --no-legend 't3code-*.scope' 2>/dev/null | grep -c .)"
17
+ awk '/MemTotal/{t=$2} /MemAvailable/{a=$2} /SwapTotal/{st=$2} /SwapFree/{sf=$2} END{print "memfree=" int(a*100/t); print "memtotalmb=" int(t/1024); if (st>0) print "swap=" int((st-sf)*100/st); else print "swap=0"}' /proc/meminfo
16
18
  fi
17
19
  echo "diskmb=$(df -Pk "$HOME" | awk 'NR==2{print int($4/1024)}')"
20
+ echo "disktotalmb=$(df -Pk "$HOME" | awk 'NR==2{print int($2/1024)}')"
18
21
  echo "epoch=$(date +%s)"
@@ -31,7 +31,7 @@
31
31
  "versions": "any",
32
32
  "architectures": "any",
33
33
  "status": "refused",
34
- "note": "UBUNTU_BASELINE_MISMATCH"
34
+ "note": "UBUNTU_BASELINE_MISMATCH; not yet supported (see below)."
35
35
  },
36
36
  {
37
37
  "role": "worker",
@@ -39,7 +39,7 @@
39
39
  "versions": "any",
40
40
  "architectures": "any",
41
41
  "status": "refused",
42
- "note": "The fleet configuration accepts only Linux workers."
42
+ "note": "The fleet configuration accepts only Linux workers; not yet supported (see below)."
43
43
  },
44
44
  {
45
45
  "role": "worker",
@@ -10,3 +10,8 @@ dependencies = ["ansible-core==2.19.9"]
10
10
 
11
11
  [tool.uv]
12
12
  package = false
13
+
14
+ # The linter CI runs on every playbook (`uv run --locked --group lint
15
+ # ansible-lint`). A separate group: the manager never installs it.
16
+ [dependency-groups]
17
+ lint = ["ansible-lint==26.11.0"]
@@ -7,8 +7,13 @@
7
7
  vars_files:
8
8
  - policy.json
9
9
  pre_tasks:
10
- - ansible.builtin.import_tasks: tasks/release.yml
10
+ - name: Require a supported Ubuntu release
11
+ ansible.builtin.import_tasks: tasks/release.yml
11
12
  tags: [always]
13
+ - name: Require the minimum worker size and size the disk and memory lines
14
+ ansible.builtin.import_tasks: tasks/sizing.yml
15
+ - name: Leave out what the features this worker lacks would install
16
+ ansible.builtin.import_tasks: tasks/features.yml
12
17
  - name: Require an explicit non-root user
13
18
  ansible.builtin.assert:
14
19
  that:
@@ -27,10 +32,12 @@
27
32
  - ansible_facts.getent_passwd[development_user][1] | int == development_uid | int
28
33
  - ansible_facts.getent_passwd[development_user][4] == development_home
29
34
  tags: [always]
30
- - ansible.builtin.import_tasks: tasks/dpkg.yml
35
+ - name: Recover or refuse a broken package database
36
+ ansible.builtin.import_tasks: tasks/dpkg.yml
31
37
  # Memory and disk safety must land even when the disk is nearly full.
32
38
  # fleet-repair re-applies them alone (--tags disk) for worker:capacity:*.
33
- - ansible.builtin.import_tasks: tasks/resources.yml
39
+ - name: Apply memory and disk safety
40
+ ansible.builtin.import_tasks: tasks/resources.yml
34
41
  tags: [disk]
35
42
  - name: Inspect capacity before package and service changes
36
43
  ansible.builtin.command:
@@ -43,11 +50,12 @@
43
50
  environment:
44
51
  FLEET_HOME: '{{ development_home }}'
45
52
  changed_when: false
53
+ check_mode: false
46
54
  register: capacity
47
55
  - name: Preserve management capacity instead of installing into an exhausted disk
48
56
  ansible.builtin.assert:
49
57
  that:
50
- - item.freeMiB >= minimumFreeDiskMiB
58
+ - item.freeMiB >= worker_minimum_free_disk_mib | int
51
59
  - item.freeInodes >= minimumFreeInodes
52
60
  fail_msg: RESOURCE_CAPACITY_REQUIRED; inspect disk space and inodes on the reported filesystem. Preserve developer work; the disk GC removes only build output and caches, so what remains needs a person.
53
61
  loop: '{{ capacity.stdout | from_json }}'
@@ -58,18 +66,36 @@
58
66
  ansible.builtin.assert:
59
67
  that: "'docker-ce' not in ansible_facts.packages and 'containerd.io' not in ansible_facts.packages"
60
68
  fail_msg: DOCKER_OWNER_CONFLICT; migrate the existing Docker owner explicitly before applying the Ubuntu-native baseline.
69
+ when: worker_containers_on | bool
61
70
  - name: Inspect existing service ownership
62
71
  ansible.builtin.service_facts:
63
72
  tasks:
64
- - ansible.builtin.import_tasks: tasks/packages.yml
65
- - ansible.builtin.import_tasks: tasks/docker.yml
66
- - ansible.builtin.import_tasks: tasks/browser.yml
73
+ - name: Install the pinned packages
74
+ ansible.builtin.import_tasks: tasks/packages.yml
75
+ - name: Configure Docker
76
+ ansible.builtin.import_tasks: tasks/docker.yml
77
+ when: worker_containers_on | bool
78
+ - name: Remove what dev-fleet installed for containers
79
+ ansible.builtin.import_tasks: tasks/containers-off.yml
80
+ when: not worker_containers_on | bool
81
+ - name: Install the browser for web workers
82
+ ansible.builtin.import_tasks: tasks/browser.yml
67
83
  when: "'web' in worker_tool_profiles | default([])"
68
- - ansible.builtin.import_tasks: tasks/services.yml
69
- - ansible.builtin.import_tasks: tasks/power.yml
70
- - ansible.builtin.import_tasks: tasks/user.yml
71
- - ansible.builtin.import_tasks: tasks/ssh.yml
72
- - ansible.builtin.import_tasks: tasks/credentials.yml
84
+ - name: Keep the always-on services running
85
+ ansible.builtin.import_tasks: tasks/services.yml
86
+ - name: Apply the power policy
87
+ ansible.builtin.import_tasks: tasks/power.yml
88
+ - name: Configure the development user
89
+ ansible.builtin.import_tasks: tasks/user.yml
90
+ - name: Configure SSH
91
+ ansible.builtin.import_tasks: tasks/ssh.yml
92
+ # A container's network belongs to its host, which owns the firewall.
93
+ - name: Admit inbound traffic only over loopback and the tailnet
94
+ ansible.builtin.import_tasks: tasks/firewall.yml
95
+ when: ansible_facts.virtualization_type not in ['docker', 'container', 'podman', 'lxc', 'systemd-nspawn']
96
+ - name: Install the credential helpers
97
+ ansible.builtin.import_tasks: tasks/credentials.yml
73
98
  when: development_source is defined
74
- - ansible.builtin.import_tasks: tasks/github.yml
99
+ - name: Sign the worker in to GitHub
100
+ ansible.builtin.import_tasks: tasks/github.yml
75
101
  when: development_source is defined
@@ -32,7 +32,7 @@ the last one says how many MiB were reclaimed.
32
32
 
33
33
  Usage: disk-gc.py --home H [--path P ...] [--idle-days N] [--pressure-mib N]
34
34
  [--pressure-inode-percent N] [--pressure-idle-hours N] [--docker-age-hours N]
35
- [--pressure-docker-age-hours N] [--pressure-cache DIR ...]
35
+ [--pressure-docker-age-hours N] [--no-docker] [--pressure-cache DIR ...]
36
36
  [--worktree-root DIR ...] [--worktree-idle-days N]
37
37
  [--cache-cap DIR=MIB ...] [--keep-newest DIR=N ...]
38
38
  [--mise PATH] [--user NAME]
@@ -476,6 +476,9 @@ def main(argv):
476
476
  parser.add_argument('--pressure-idle-hours', type=float, default=12)
477
477
  parser.add_argument('--docker-age-hours', type=int, default=168)
478
478
  parser.add_argument('--pressure-docker-age-hours', type=int, default=24)
479
+ # A worker without containers (fleet.json features.containers) has no
480
+ # Docker to prune.
481
+ parser.add_argument('--no-docker', action='store_true')
479
482
  parser.add_argument('--pressure-cache', action='append', default=[])
480
483
  parser.add_argument('--worktree-root', action='append', default=[])
481
484
  parser.add_argument('--worktree-idle-days', type=float, default=30)
@@ -523,11 +526,12 @@ def main(argv):
523
526
  prune = ['runuser', '-u', args.user, '--', 'env', f'HOME={home}'] + prune
524
527
  command(prune, args.dry_run, 'mise_prune')
525
528
  reclaimed += max(0, before_mise - size_of(installs))
526
- if not args.home_only:
529
+ if not args.home_only and not args.no_docker:
527
530
  command(['docker', 'image', 'prune', '--all', '--force', '--filter',
528
531
  f'until={docker_age}h'], args.dry_run, 'docker_images')
529
532
  command(['docker', 'builder', 'prune', '--all', '--force', '--filter',
530
533
  f'until={docker_age}h'], args.dry_run, 'docker_build_cache')
534
+ if not args.home_only:
531
535
  command(['apt-get', 'clean'], args.dry_run, 'apt_cache')
532
536
 
533
537
  after = {p: free_bytes(p) for p in paths}
@@ -0,0 +1,50 @@
1
+ # Managed by personal Fleet; inbound only over loopback and the tailnet (worker exposure policy).
2
+ # Loaded at boot by personal-fleet-firewall.service. Replaces only its own
3
+ # table: Docker's and Tailscale's tables are left to their owners.
4
+ table inet personal_fleet
5
+ delete table inet personal_fleet
6
+
7
+ table inet personal_fleet {
8
+ chain input {
9
+ type filter hook input priority filter - 10; policy drop;
10
+ iif "lo" accept
11
+ ct state invalid drop
12
+ ct state established,related accept
13
+ iifname "{{ exposure.tailnetInterface }}" accept
14
+ {% if firewall_docker | bool %}
15
+ {% for interface in exposure.containerInterfaces %}
16
+ iifname "{{ interface }}" accept comment "containers reach the host"
17
+ {% endfor %}
18
+ {% endif %}
19
+ udp dport {{ exposure.tailscalePort }} accept comment "tailscaled"
20
+ meta l4proto ipv6-icmp accept
21
+ meta l4proto icmp accept
22
+ udp sport 67 udp dport 68 accept comment "dhcp"
23
+ ip6 saddr fe80::/10 udp sport 547 udp dport 546 accept comment "dhcpv6"
24
+ {% if firewall_public_ssh | bool %}
25
+ {% for port in firewall_ssh_ports %}
26
+ tcp dport {{ port }} accept comment "public-ssh"
27
+ {% endfor %}
28
+ {% endif %}
29
+ {% for entry in firewall_policy.publicPorts | default([]) %}
30
+ {{ entry.protocol }} dport {{ entry.port }} accept comment "public-port"
31
+ {% endfor %}
32
+ }
33
+ {% if firewall_docker | bool %}
34
+
35
+ # A published container port is reached by forwarding, not input: from
36
+ # outside the tailnet it is dropped before Docker's own chains accept it.
37
+ chain forward {
38
+ type filter hook forward priority filter - 10; policy accept;
39
+ ct state established,related accept
40
+ iifname "{{ exposure.tailnetInterface }}" accept
41
+ {% for interface in exposure.containerInterfaces %}
42
+ iifname "{{ interface }}" accept
43
+ {% endfor %}
44
+ ct status dnat drop comment "container-port"
45
+ {% for interface in exposure.containerInterfaces %}
46
+ oifname "{{ interface }}" drop comment "container-port"
47
+ {% endfor %}
48
+ }
49
+ {% endif %}
50
+ }