@crvouga/dev-fleet 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +265 -37
  2. package/agents/setup.md +42 -12
  3. package/ansible/macos/manager.yml +10 -7
  4. package/ansible/macos/policy.json +2 -10
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/README.md +86 -25
  7. package/ansible/monitoring/agent.yml +40 -2
  8. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  9. package/ansible/monitoring/lock.json +5 -0
  10. package/ansible/pyproject.toml +12 -0
  11. package/ansible/ubuntu/baseline.yml +1 -1
  12. package/ansible/ubuntu/disk-gc.py +336 -0
  13. package/ansible/ubuntu/policy.json +13 -3
  14. package/ansible/ubuntu/t3-desktop.yml +19 -0
  15. package/ansible/ubuntu/t3-health.sh +93 -22
  16. package/ansible/ubuntu/t3-idle.py +42 -0
  17. package/ansible/ubuntu/t3-policy.yml +4 -11
  18. package/ansible/ubuntu/tailscale.yml +8 -5
  19. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  20. package/ansible/ubuntu/tasks/resources.yml +61 -1
  21. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  22. package/ansible/ubuntu/wip-guard.py +121 -0
  23. package/ansible/uv.lock +190 -0
  24. package/bootstrap/bootstrap.sh +25 -18
  25. package/bootstrap/doctor.ts +46 -1
  26. package/bootstrap/fleet-manager-run.sh +42 -15
  27. package/bootstrap/fleet-remote-bootstrap.sh +85 -12
  28. package/bootstrap/global-config.ts +64 -6
  29. package/bootstrap/install-launcher.sh +3 -0
  30. package/bootstrap/native-t3.ts +22 -11
  31. package/bootstrap/npm.ts +10 -2
  32. package/bootstrap/release-version.ts +157 -0
  33. package/cliproxy/README.md +3 -3
  34. package/cliproxy/scripts/accounts.py +216 -0
  35. package/cliproxy/scripts/checkpoint.py +29 -1
  36. package/cliproxy/scripts/cli.py +1 -1
  37. package/cliproxy/scripts/clientkeys.py +99 -0
  38. package/cliproxy/scripts/lease.py +80 -0
  39. package/cliproxy/scripts/ops +137 -25
  40. package/cliproxy/scripts/restore-verify +15 -0
  41. package/docs/accounts.md +154 -0
  42. package/docs/consumer-setup.md +408 -0
  43. package/docs/declared.md +91 -0
  44. package/docs/durability.md +26 -0
  45. package/docs/machine-setup.md +400 -0
  46. package/docs/operations.md +276 -0
  47. package/docs/releasing.md +103 -0
  48. package/docs/state.md +254 -0
  49. package/docs/topology.md +69 -0
  50. package/docs/ubuntu-sync.md +357 -0
  51. package/docs/worker-contract.md +67 -0
  52. package/examples/fleet.json +5 -0
  53. package/llms.txt +36 -0
  54. package/mise.lock +29 -0
  55. package/mise.toml +15 -2
  56. package/package.json +11 -2
  57. package/schema/fleet.schema.json +137 -15
  58. package/src/accounts/checks.ts +220 -0
  59. package/src/accounts/context.ts +76 -0
  60. package/src/accounts/propagate.ts +62 -0
  61. package/src/accounts/roster.ts +85 -0
  62. package/src/accounts/selection.ts +67 -0
  63. package/src/accounts/sessions.ts +120 -0
  64. package/src/agents/account-relay.ts +176 -26
  65. package/src/agents/fleet-verify.ts +9 -0
  66. package/src/agents/launch.ts +84 -5
  67. package/src/agents/native-verify.ts +293 -22
  68. package/src/agents/provider-errors.ts +9 -2
  69. package/src/agents/registry.ts +7 -1
  70. package/src/cli/agent-guide.ts +39 -5
  71. package/src/cli/app.ts +11 -4
  72. package/src/cli/cli.ts +7 -0
  73. package/src/cli/command-index.ts +7 -0
  74. package/src/cli/fleet-accounts.ts +253 -0
  75. package/src/cli/fleet-state.ts +499 -0
  76. package/src/cli/fleet-sync-accounts.ts +58 -0
  77. package/src/cli/fleet-sync-checkout.ts +56 -17
  78. package/src/cli/fleet-sync-enrollment.ts +19 -23
  79. package/src/cli/fleet-sync-forward.ts +1 -1
  80. package/src/cli/fleet-sync-manager.ts +84 -12
  81. package/src/cli/fleet-sync-monitoring.ts +5 -5
  82. package/src/cli/fleet-sync-output.ts +7 -3
  83. package/src/cli/fleet-sync-remote.ts +31 -18
  84. package/src/cli/fleet-sync-state.ts +86 -0
  85. package/src/cli/fleet-sync.ts +159 -57
  86. package/src/cli/fleet.ts +4 -2
  87. package/src/cli/managed-health.ts +4 -4
  88. package/src/cli/node-sync.ts +7 -0
  89. package/src/cli/program.ts +20 -4
  90. package/src/cli/project.ts +51 -0
  91. package/src/cli/register-fleet.ts +86 -1
  92. package/src/cli/registry.ts +41 -1
  93. package/src/cli/roster.ts +46 -30
  94. package/src/cli/settings.ts +15 -12
  95. package/src/cli/source-operations.ts +27 -3
  96. package/src/cli/tailnet.ts +2 -2
  97. package/src/definition.ts +116 -4
  98. package/src/diagnostics.ts +1 -1
  99. package/src/fleet-config.ts +105 -19
  100. package/src/ide/definition.ts +86 -0
  101. package/src/ide/ide.ts +43 -0
  102. package/src/ide/index.ts +5 -0
  103. package/src/ide/orchestrate.ts +50 -0
  104. package/src/ide/t3.ts +111 -0
  105. package/src/machine/source.ts +3 -3
  106. package/src/machine/state.ts +15 -27
  107. package/src/manager-sync.ts +9 -18
  108. package/src/monitoring/agent-evidence.ts +146 -0
  109. package/src/monitoring/apply.ts +15 -3
  110. package/src/monitoring/beszel.ts +11 -4
  111. package/src/monitoring/enroll.ts +2 -1
  112. package/src/monitoring/hub-access.ts +4 -3
  113. package/src/monitoring/hub-backup.ts +121 -0
  114. package/src/monitoring/hub-viewer.ts +8 -8
  115. package/src/monitoring/hub.ts +3 -3
  116. package/src/monitoring/manager-hub.ts +30 -14
  117. package/src/node-converge-linux.ts +30 -7
  118. package/src/package-info.ts +45 -0
  119. package/src/platform/ansible.ts +19 -15
  120. package/src/platform/darwin.ts +0 -13
  121. package/src/platform/fallback.ts +0 -8
  122. package/src/platform/linux.ts +0 -10
  123. package/src/platform/resource-verify.ts +8 -4
  124. package/src/platform/t3-policy.ts +33 -6
  125. package/src/platform/t3-service.ts +104 -0
  126. package/src/platform/types.ts +0 -4
  127. package/src/platform/ubuntu-audit.ts +5 -2
  128. package/src/platform/windows.ts +0 -14
  129. package/src/remote-bootstrap.ts +17 -19
  130. package/src/remote-entry.ts +15 -1
  131. package/src/repair-catalog.ts +1 -1
  132. package/src/runner.ts +1 -1
  133. package/src/service-auth.ts +15 -5
  134. package/src/state/bucket.ts +208 -0
  135. package/src/state/check.ts +74 -0
  136. package/src/state/connection.ts +92 -0
  137. package/src/state/enrollment.ts +45 -0
  138. package/src/state/legacy.ts +89 -0
  139. package/src/state/litestream.ts +143 -0
  140. package/src/state/operator.ts +20 -0
  141. package/src/state/paths.ts +40 -0
  142. package/src/state/roster.ts +62 -0
  143. package/src/state/store.ts +169 -0
  144. package/src/state/sync.ts +352 -0
  145. package/src/t3/accounts.ts +7 -1
  146. package/src/t3/models.ts +133 -0
  147. package/src/t3/pairing.ts +31 -0
  148. package/src/t3/profile.ts +203 -0
  149. package/src/t3/version.ts +119 -0
  150. package/src/t3-endpoint.ts +64 -8
  151. package/src/t3-linked-check.ts +24 -2
  152. package/src/toolchain-checks.ts +25 -0
  153. package/src/transport.ts +3 -2
  154. package/src/types.ts +4 -0
  155. package/src/worker-access-checks.ts +56 -3
  156. package/src/worker-host-checks.ts +41 -13
  157. package/src/workstation-checks.ts +1 -1
  158. package/src/index.ts +0 -6
  159. package/src/test-fixtures.ts +0 -36
@@ -1,19 +1,20 @@
1
1
  # Beszel owner and recovery
2
2
 
3
- Fleet's [manager guide](../../fleet/docs/ubuntu-sync.md) is the normal entry
4
- point: `devfleet fleet-sync`, run on the manager. Workstation owns the
5
- pinned Compose hub, native agent units and local Ansible tasks here. Fleet owns
6
- membership, registration and freshness evidence. `lock.json` pins Beszel 0.21.1
7
- and the native agent checksums. Never run an unpinned upstream installer
8
- alongside these services.
3
+ The [README](../../README.md) and the [manager guide](../../docs/ubuntu-sync.md)
4
+ are the normal entry points: `devfleet fleet-sync`, run on the manager. This
5
+ directory holds the pinned Compose hub, the native agent unit and their
6
+ playbooks. `src/monitoring/` enrolls machines and measures registration,
7
+ connection, freshness and backups. `lock.json` pins the Beszel release, the
8
+ agent archives and the agent executables by digest. Never run an unpinned
9
+ upstream installer alongside these services.
9
10
 
10
11
  ## Manager and workers
11
12
 
12
- The fleet has exactly two roles (Fleet `definition.json`, version 3):
13
+ The fleet has exactly two roles (`fleet.json`):
13
14
 
14
15
  - **Manager** — the human's macOS machine. It runs fleet-sync locally and hosts the
15
- singleton hub (`cli/lib/monitoring/manager-hub.ts`, `convergeManagerHub`). Fleet
16
- keeps it awake on AC power. Nothing is installed on it over SSH.
16
+ singleton hub (`src/monitoring/manager-hub.ts`, `convergeManagerHub`).
17
+ dev-fleet keeps it awake on AC power. Nothing is installed on it over SSH.
17
18
  - **Workers** — the stateless Linux boxes. They only run the native agent
18
19
  (`agent.yml`, a hardened systemd unit) and connect to the manager's private hub
19
20
  URL. A worker refuses hub mode (`BESZEL_NOT_HUB`: the hub runs only on the
@@ -77,13 +78,42 @@ unavailable the step warns and the hub shows its normal login page; create the
77
78
  user by hand (Settings > Users, role **Read-only**) as a fallback. Never point
78
79
  `AUTO_LOGIN` at an admin account. The fleet admin login is still needed to change anything.
79
80
 
81
+ ## The agent on a worker
82
+
80
83
  Native agents use outbound authenticated WebSockets, per-node tokens and
81
84
  persistent identity. They do not need inbound SSH monitoring ports, Docker socket
82
85
  access or journals.
83
- `SKIP_SYSTEMD_LOGS=true` deliberately excludes potentially sensitive service logs.
84
- System services may be visible; user T3 service information is not certified by
85
- that fact. Missing sensors, SMART privileges, Docker stats and user-service metrics
86
- remain unavailable until their specific supported permissions are reviewed.
86
+
87
+ - **Unprivileged.** The unit runs as the `beszel` system account with no other
88
+ group, no capability, no journal and no Docker socket
89
+ (`features/monitoring/agent-privileges.feature`).
90
+ `SKIP_SYSTEMD_LOGS=true` deliberately excludes potentially sensitive service
91
+ logs. System services may be visible; user T3 service information is not
92
+ certified by that fact. Sensors, SMART, Docker stats and user-service metrics
93
+ stay unavailable until their specific permissions are reviewed: nothing grants
94
+ them by default.
95
+ - **Repeatable.** `agent.yml` compares the installed executable with the digest
96
+ of the pinned build (`agentBinaries` in `lock.json`). An unchanged agent is left
97
+ running; one an interrupted upgrade or an edit left behind is removed,
98
+ extracted again from the checksummed archive and restarted once. Edited unit
99
+ or connection settings are restored the same way.
100
+ - **Boots without a login.** It is a system unit (`WantedBy=multi-user.target`),
101
+ so it starts at boot with nobody signed in.
102
+ - **Measured from the worker's side** (`src/monitoring/agent-evidence.ts`):
103
+
104
+ | Check | Summary | Meaning |
105
+ | ----------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------- |
106
+ | `beszel:disk` | `BESZEL_DISK_FULL`, `BESZEL_DISK_UNVERIFIED` | Less than 256 MiB free where the agent installs; nothing was changed |
107
+ | `beszel:service` | `BESZEL_SERVICE_STOPPED` | The unit is not enabled and active |
108
+ | `beszel:hub-connection` | `BESZEL_AGENT_TOKEN_REJECTED` | The hub answered the agent's token with 401 |
109
+ | | `BESZEL_AGENT_KEY_REJECTED` | The hub did not prove the key the agent was given |
110
+ | | `BESZEL_HUB_UNAVAILABLE_TO_AGENT` (warn) | The private route answered 502–504: the hub behind it is down |
111
+ | | `BESZEL_AGENT_CONNECTION_PENDING`, `…_LOG_UNAVAILABLE` (skip) | No outcome yet, or the log could not be read |
112
+
113
+ The outcome is the newest of a few fixed texts the pinned agent logs, taken
114
+ from its source; nothing of the log is reported. The hub's own view
115
+ (registration, `BESZEL_AGENT_DISCONNECTED`, `BESZEL_METRICS_STALE`) is
116
+ measured separately by `fleet-status --workers`.
87
117
 
88
118
  Accounts need no manual step. On a hub's first start (empty data directory),
89
119
  `hub-darwin.yml` passes Beszel's `USER_EMAIL`/`USER_PASSWORD` once, so the hub
@@ -97,20 +127,32 @@ A legacy JSON `identity`/`password` value is still accepted. The manager's exist
97
127
  Vault credential must renew unattended. Superuser credentials are not distributed
98
128
  to nodes or needed for enrollment. Revocation produces action-required results.
99
129
 
100
- Configure network monitors and alert thresholds once in the stock UI; these
101
- settings are runtime application data, preserved in backups. Select explicit probe
102
- origins rather than an all-to-all mesh. Use sustained resource-pressure alerts,
103
- disk capacity, offline nodes and supported temperatures. Do not send notifications
104
- until an authorized destination exists. Service-failure alert delivery has not
105
- been accepted against this pinned release.
130
+ ## Settings kept only in the hub's UI
131
+
132
+ Beszel keeps network monitors, alert rules and backup settings in its own data,
133
+ set in its UI. dev-fleet does not write them: there is no reconciler and no
134
+ endpoint beyond the stock API (`features/monitoring/stock-api.feature`). They
135
+ are preserved by the hub's backups. Set them once, as the fleet account:
136
+
137
+ - **Network monitors.** Add them per system from that system's page. Probe from
138
+ a few chosen origins (for example the manager to each worker) rather than an
139
+ all-to-all mesh, so a failed check names a path you can reason about.
140
+ - **Alerts.** Prefer sustained thresholds over instant ones: CPU, memory and
141
+ disk above a level for several minutes, disk capacity, a system going offline,
142
+ and temperatures where the sensor is actually reported. Do not add a
143
+ notification destination until one you are allowed to use exists.
144
+ - **Service alerts are not promised.** Alerting on a failed systemd service has
145
+ not been verified against the pinned release; the agent watches only the
146
+ services in `SERVICE_PATTERNS`, and none of them is relied on for alerting.
106
147
 
107
148
  ## Stock backup and restore
108
149
 
109
150
  1. Open the hub's private PocketBase administration route `/_/` and authenticate
110
151
  as its superuser. Keep that credential separate from fleet enrollment access.
111
152
  2. Open **Settings → Backups**. Create and download a backup before maintenance.
112
- Set scheduled backups and retention there using existing authorized storage.
113
- Do not provision a bucket or reuse unrelated credentials to bypass access gates.
153
+ Set a schedule, how many to keep, and S3-compatible storage there, using
154
+ storage you already approved. Do not provision a bucket or reuse unrelated
155
+ credentials to bypass access gates.
114
156
  3. Protect downloaded archives as secrets: they contain registrations, tokens,
115
157
  account data, alert configuration and history. Use the existing approved
116
158
  encrypted backup destination; a backup on the same disk is not disaster recovery.
@@ -122,6 +164,17 @@ been accepted against this pinned release.
122
164
  5. Run the manager command and require new metric timestamps after recovery.
123
165
  Old historical samples do not prove agent connectivity.
124
166
 
167
+ Every hub converge in `fleet-sync` reads these settings back as the hub's
168
+ superuser (`GET /api/settings`, `GET /api/backups`; `src/monitoring/hub-backup.ts`)
169
+ and reports `beszel:backup`. It only warns, and never names the storage:
170
+
171
+ | Summary | Meaning |
172
+ | -------------------------------- | -------------------------------------------------------- |
173
+ | `BESZEL_BACKUP_UNSCHEDULED` | No backup schedule is set |
174
+ | `BESZEL_BACKUP_OFFHOST_REQUIRED` | Backups stay on the manager's disk |
175
+ | `BESZEL_BACKUP_STALE` | A schedule is set but no backup is newer than 48 hours |
176
+ | `BESZEL_BACKUP_UNVERIFIED` | The settings could not be read (no fleet login, refused) |
177
+
125
178
  ## Moving the hub to the manager
126
179
 
127
180
  The hub previously ran on worker `worker-one` (`/var/lib/personal-beszel`,
@@ -143,10 +196,18 @@ never run two copies of its identity or two tunnel connectors:
143
196
  Vault grant (OpenTofu).
144
197
 
145
198
  `tests/upstream.py` exercises real pinned binaries with disposable loopback data:
146
- authenticated registration, fresh metrics, restart identity preservation, no duplicate
147
- record, and the supported PocketBase backup/restore API. It does not contact live
148
- nodes, prove Tailscale routing, verify systemd reboot, or test an off-host encrypted
149
- backup destination. Those remain explicit release/operational gates.
199
+ authenticated registration, fresh metrics, restart identity preservation, no
200
+ duplicate record, the supported PocketBase backup/restore API, and the recovery
201
+ drill above: a downloaded backup restored into a second, isolated hub after the
202
+ first is fenced, keeping node IDs, fingerprints, history and settings. It does
203
+ not contact live nodes, prove Tailscale routing, verify systemd reboot, or use an
204
+ off-host storage service. Those remain operational checks:
205
+
206
+ - From a second tailnet machine, `devfleet fleet-status --workers` reports
207
+ `beszel:registration`, `beszel:connection` and `beszel:freshness` passing for
208
+ every worker.
209
+ - `beszel:backup` passes on the manager once off-host storage is configured.
210
+ - After a worker reboots, the same checks pass again without anyone signing in.
150
211
 
151
212
  References: [agent](https://beszel.dev/guide/agent-installation),
152
213
  [systemd](https://beszel.dev/guide/systemd),
@@ -13,6 +13,7 @@
13
13
  that:
14
14
  - ansible_facts.distribution == 'Ubuntu'
15
15
  - beszel_arch in beszel_lock.agents
16
+ - beszel_arch in beszel_lock.agentBinaries
16
17
  - beszel_node is match('^[a-z][a-z0-9-]{0,62}$')
17
18
  - beszel_hub_url is match('^https://[a-z0-9.-]+\.ts\.net:8443$')
18
19
  - name: Inspect existing native service ownership
@@ -46,7 +47,7 @@
46
47
  loop:
47
48
  - { path: /etc/personal-beszel, group: beszel, mode: '0750' }
48
49
  - {
49
- path: '/usr/local/lib/personal-beszel/0.21.1',
50
+ path: '/usr/local/lib/personal-beszel/{{ beszel_lock.version }}',
50
51
  group: root,
51
52
  mode: '0755',
52
53
  }
@@ -88,12 +89,49 @@
88
89
  checksum: 'sha256:{{ beszel_lock.agents[beszel_arch] }}'
89
90
  mode: '0644'
90
91
  timeout: 60
91
- - name: Extract the pinned executable and repair file drift
92
+ # The executable is compared with the digest of the pinned build, so an
93
+ # unchanged agent is left alone (no restart) and one that an interrupted
94
+ # upgrade or an edit left behind is replaced.
95
+ - name: Inspect the installed executable
96
+ ansible.builtin.stat:
97
+ path: '/usr/local/lib/personal-beszel/{{ beszel_lock.version }}/beszel-agent'
98
+ checksum_algorithm: sha256
99
+ follow: false
100
+ register: agent_binary
101
+ - name: Remove an executable that is not the pinned build
102
+ ansible.builtin.file:
103
+ path: '/usr/local/lib/personal-beszel/{{ beszel_lock.version }}/beszel-agent'
104
+ state: absent
105
+ when: >-
106
+ agent_binary.stat.exists and (not agent_binary.stat.isreg
107
+ or agent_binary.stat.checksum != beszel_lock.agentBinaries[beszel_arch])
108
+ - name: Extract the pinned executable when it is missing
92
109
  ansible.builtin.unarchive:
93
110
  src: '/usr/local/lib/personal-beszel/{{ beszel_lock.version }}/agent.tar.gz'
94
111
  dest: '/usr/local/lib/personal-beszel/{{ beszel_lock.version }}'
95
112
  remote_src: true
113
+ include: [beszel-agent]
114
+ creates: '/usr/local/lib/personal-beszel/{{ beszel_lock.version }}/beszel-agent'
96
115
  notify: Restart agent
116
+ - name: Inspect the extracted executable
117
+ ansible.builtin.stat:
118
+ path: '/usr/local/lib/personal-beszel/{{ beszel_lock.version }}/beszel-agent'
119
+ checksum_algorithm: sha256
120
+ follow: false
121
+ register: agent_extracted
122
+ - name: Require the pinned build before the service may start it
123
+ ansible.builtin.assert:
124
+ that:
125
+ - agent_extracted.stat.exists
126
+ - agent_extracted.stat.isreg
127
+ - agent_extracted.stat.checksum == beszel_lock.agentBinaries[beszel_arch]
128
+ fail_msg: The extracted Beszel agent does not match the pinned build.
129
+ - name: Keep the executable owned by root and not writable by the agent
130
+ ansible.builtin.file:
131
+ path: '/usr/local/lib/personal-beszel/{{ beszel_lock.version }}/beszel-agent'
132
+ owner: root
133
+ group: root
134
+ mode: '0755'
97
135
  - name: Install nonsecret node connection settings
98
136
  ansible.builtin.copy:
99
137
  dest: /etc/personal-beszel/agent.env
@@ -0,0 +1,51 @@
1
+ ---
2
+ # The manager's hub is reached over the tailnet only: Tailscale Serve
3
+ # terminates HTTPS on the declared port and proxies to the loopback hub. Never
4
+ # Funnel. A port that already has any owner is left alone: fleet-sync refuses
5
+ # a conflicting owner before this runs and reads the route back afterwards.
6
+ - name: Serve the manager hub privately over the tailnet
7
+ hosts: localhost
8
+ connection: local
9
+ gather_facts: false
10
+ vars:
11
+ beszel_serve:
12
+ [
13
+ '{{ beszel_tailscale_cli }}',
14
+ serve,
15
+ --bg,
16
+ '--https={{ beszel_route_port }}',
17
+ '{{ beszel_route_target }}',
18
+ ]
19
+ tasks:
20
+ - name: Require the declared loopback target and port
21
+ ansible.builtin.assert:
22
+ that:
23
+ - "beszel_route_target is match('^http://127\\.0\\.0\\.1:[0-9]+$')"
24
+ - "beszel_route_port is match('^[0-9]+$')"
25
+ - "beszel_tailscale_cli is match('^/')"
26
+
27
+ - name: Read this node's Serve configuration
28
+ ansible.builtin.command:
29
+ argv: ['{{ beszel_tailscale_cli }}', serve, status, --json]
30
+ register: beszel_serve_status
31
+ changed_when: false
32
+ - name: Publish the hub on the tailnet HTTPS port as the signed-in user
33
+ ansible.builtin.command:
34
+ argv: '{{ beszel_serve }}'
35
+ vars:
36
+ serve: "{{ beszel_serve_status.stdout | default('{}', true) | from_json }}"
37
+ when:
38
+ - beszel_route_port not in (serve.TCP | default({}))
39
+ - "(serve.Web | default({})) | select('search', ':' ~ beszel_route_port ~ '$') | list | length == 0"
40
+ register: beszel_served
41
+ changed_when: beszel_served.rc == 0
42
+ failed_when: false
43
+ # The standalone daemon accepts Serve changes from an administrator only.
44
+ - name: Publish the hub with administrator rights where the daemon requires them
45
+ ansible.builtin.command:
46
+ argv: '{{ beszel_serve }}'
47
+ become: true
48
+ when:
49
+ - beszel_served is not skipped
50
+ - beszel_served.rc != 0
51
+ changed_when: true
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "version": "0.21.1",
3
3
  "image": "henrygd/beszel:0.21.1@sha256:8514a69d9bbbcb74f371a3205561ee231fc0289739cea64e8131f341d695b770",
4
+ "tunnelImage": "cloudflare/cloudflared:2026.9.3@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c",
4
5
  "agents": {
5
6
  "amd64": "3b6ee592632ec03b9a43312e99a40203a7b3d83fe40dd0c25acda1eb915ea7c0",
6
7
  "arm64": "a6ea544976b9a74398ac88d5da5bb96813d9a2e353a37bbfdb6a903fa8ba1838",
7
8
  "darwin-arm64": "9246065f680cdb0af2885b7bf5876dc45b754019f2c90eb28550cf45db978eb8",
8
9
  "darwin-amd64": "21f4a03fd90ae46869cfcbb69e21e8312f0f05deff49669d4602a3dee4866981"
9
10
  },
11
+ "agentBinaries": {
12
+ "amd64": "5c84416b63028f7ad86ebafa4c8a7a27977d485a838de357216ded3ab844b2b5",
13
+ "arm64": "eb4e7ac9340d380b19ab18261301b18813c1e46ebab5f0e4bf8cb6c86b4d5c20"
14
+ },
10
15
  "source": "https://github.com/henrygd/beszel/releases/tag/v0.21.1"
11
16
  }
@@ -0,0 +1,12 @@
1
+ # The Ansible the manager runs these playbooks with: one release, on one
2
+ # Python, with every dependency hash-locked in uv.lock. A worker runs the
3
+ # exact ansible-core package ubuntu/policy.json names instead. Change the
4
+ # version here, then run `uv lock` in this directory.
5
+ [project]
6
+ name = "dev-fleet-ansible"
7
+ version = "0"
8
+ requires-python = "==3.13.16"
9
+ dependencies = ["ansible-core==2.19.9"]
10
+
11
+ [tool.uv]
12
+ package = false
@@ -44,7 +44,7 @@
44
44
  that:
45
45
  - item.freeMiB >= minimumFreeDiskMiB
46
46
  - item.freeInodes >= minimumFreeInodes
47
- fail_msg: RESOURCE_CAPACITY_REQUIRED; inspect disk space and inodes on the reported filesystem. Preserve developer work; no automatic cleanup or process killing.
47
+ 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.
48
48
  loop: '{{ capacity.stdout | from_json }}'
49
49
  - name: Inspect installed packages
50
50
  ansible.builtin.package_facts:
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/python3
2
+ """Recover disk space by deleting only what a build or install can recreate.
3
+
4
+ Managed by personal fleet-sync (tasks/resources.yml) as
5
+ personal-fleet-disk-gc.service, started hourly by personal-fleet-disk-gc.timer.
6
+
7
+ What it deletes:
8
+ * dependency and build directories (node_modules, .turbo, dist, Rust target,
9
+ ...) inside git checkouts under the home directory, only when git says the
10
+ directory is ignored, the checkout has been idle for --idle-days and no
11
+ process is running inside it;
12
+ * Docker images and build cache that no container uses and that are older
13
+ than --docker-age-hours (never containers or volumes);
14
+ * the apt package cache.
15
+ When a watched filesystem has less than --pressure-mib free, idle checkouts
16
+ qualify after --pressure-idle-hours, Docker leftovers after
17
+ --pressure-docker-age-hours, and aged entries of each --pressure-cache
18
+ directory are removed too.
19
+
20
+ It never deletes a checkout, a tracked file, uncommitted work, a container or
21
+ a volume. Every decision is one JSON line on stdout.
22
+
23
+ Usage: disk-gc.py --home H [--path P ...] [--idle-days N] [--pressure-mib N]
24
+ [--pressure-idle-hours N] [--docker-age-hours N]
25
+ [--pressure-docker-age-hours N] [--pressure-cache DIR ...]
26
+ [--max-depth N] [--proc DIR] [--home-only] [--dry-run]
27
+
28
+ --home-only skips the machine-wide Docker and apt steps.
29
+ """
30
+ import argparse
31
+ import json
32
+ import os
33
+ import shutil
34
+ import stat
35
+ import subprocess
36
+ import sys
37
+ import time
38
+ from pathlib import Path
39
+
40
+ MIB = 1024 * 1024
41
+ HOUR = 3600
42
+ # Directory names that hold only installed dependencies or build output.
43
+ REGENERABLE = frozenset({
44
+ 'node_modules', '.cache', '.turbo', '.next', '.nuxt', '.svelte-kit', '.astro',
45
+ '.vite', '.parcel-cache', '.expo', '.gradle', 'dist', 'build', 'coverage',
46
+ '.pytest_cache', '.mypy_cache', '.ruff_cache', '__pycache__', '.venv',
47
+ })
48
+ # Directory names that are regenerable only beside the manifest that builds them.
49
+ REGENERABLE_BESIDE = {'target': 'Cargo.toml'}
50
+ # Hidden directories under the home directory that still hold checkouts.
51
+ HIDDEN_ROOTS = frozenset({'.t3'})
52
+ GIT = ['git', '-c', 'safe.directory=*']
53
+
54
+
55
+ def log(**event):
56
+ print(json.dumps(event, sort_keys=True), flush=True)
57
+
58
+
59
+ def free_bytes(path):
60
+ st = os.statvfs(path)
61
+ return st.f_bavail * st.f_frsize
62
+
63
+
64
+ def mtime(path):
65
+ try:
66
+ return os.lstat(path).st_mtime
67
+ except OSError:
68
+ return 0.0
69
+
70
+
71
+ def is_real_dir(path):
72
+ try:
73
+ return stat.S_ISDIR(os.lstat(path).st_mode)
74
+ except OSError:
75
+ return False
76
+
77
+
78
+ def find_checkouts(home, max_depth):
79
+ """Git checkouts (repositories and worktrees) under home, outermost only."""
80
+ found = []
81
+
82
+ def walk(directory, depth):
83
+ if os.path.lexists(os.path.join(directory, '.git')):
84
+ found.append(directory)
85
+ return
86
+ if depth >= max_depth:
87
+ return
88
+ try:
89
+ entries = list(os.scandir(directory))
90
+ except OSError:
91
+ return
92
+ for entry in entries:
93
+ if not entry.is_dir(follow_symlinks=False):
94
+ continue
95
+ name = entry.name
96
+ if name in REGENERABLE or name in REGENERABLE_BESIDE:
97
+ continue
98
+ if name.startswith('.') and not (depth == 0 and name in HIDDEN_ROOTS):
99
+ continue
100
+ walk(entry.path, depth + 1)
101
+
102
+ walk(home, 0)
103
+ return sorted(found)
104
+
105
+
106
+ def git_dir(checkout):
107
+ """The checkout's git directory, following a worktree's .git file."""
108
+ dot_git = os.path.join(checkout, '.git')
109
+ if is_real_dir(dot_git):
110
+ return dot_git
111
+ try:
112
+ text = Path(dot_git).read_text()
113
+ except OSError:
114
+ return None
115
+ if not text.startswith('gitdir:'):
116
+ return None
117
+ target = text[len('gitdir:'):].strip()
118
+ target = os.path.normpath(os.path.join(checkout, target))
119
+ return target if is_real_dir(target) else None
120
+
121
+
122
+ def run_git(checkout, args, data=None):
123
+ try:
124
+ out = subprocess.run(GIT + ['-C', checkout] + args, input=data,
125
+ capture_output=True, timeout=600)
126
+ except (OSError, subprocess.TimeoutExpired):
127
+ return None
128
+ return out
129
+
130
+
131
+ def candidates(checkout):
132
+ """Regenerable directories in a checkout, outermost only, never through a link."""
133
+ found = []
134
+ for current, dirs, files in os.walk(checkout):
135
+ keep = []
136
+ for name in dirs:
137
+ path = os.path.join(current, name)
138
+ if name == '.git' or os.path.islink(path):
139
+ continue
140
+ if os.path.lexists(os.path.join(path, '.git')):
141
+ continue # a nested repository answers for itself
142
+ beside = REGENERABLE_BESIDE.get(name)
143
+ if name in REGENERABLE or (beside and beside in files):
144
+ found.append(path)
145
+ continue
146
+ keep.append(name)
147
+ dirs[:] = keep
148
+ return found
149
+
150
+
151
+ def ignored(checkout, paths):
152
+ """The subset of paths git ignores; tracked or unignored paths are kept."""
153
+ if not paths:
154
+ return []
155
+ rel = [os.path.relpath(p, checkout) for p in paths]
156
+ out = run_git(checkout, ['check-ignore', '--stdin', '-z'],
157
+ data=('\0'.join(rel) + '\0').encode())
158
+ if out is None or out.returncode not in (0, 1):
159
+ return []
160
+ hits = {item for item in out.stdout.decode(errors='replace').split('\0') if item}
161
+ return [p for p, r in zip(paths, rel) if r in hits or r.rstrip('/') in hits]
162
+
163
+
164
+ def git_activity(checkout, gdir):
165
+ """The latest commit, checkout or index refresh: cheap, read first."""
166
+ marks = [checkout, os.path.join(gdir, 'index'), os.path.join(gdir, 'HEAD'),
167
+ os.path.join(gdir, 'logs', 'HEAD')]
168
+ return max(mtime(p) for p in marks)
169
+
170
+
171
+ def last_activity(checkout, gdir, dirs):
172
+ """The latest sign of work: git activity, changed files, build output writes."""
173
+ latest = max([git_activity(checkout, gdir)] + [mtime(p) for p in dirs])
174
+ out = run_git(checkout, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
175
+ if out is None or out.returncode != 0:
176
+ return None
177
+ entries = out.stdout.decode(errors='replace').split('\0')
178
+ for entry in entries:
179
+ if len(entry) > 3:
180
+ latest = max(latest, mtime(os.path.join(checkout, entry[3:])))
181
+ return latest
182
+
183
+
184
+ def busy_roots(proc):
185
+ """Working directories of every running process."""
186
+ roots = set()
187
+ try:
188
+ pids = [p for p in os.listdir(proc) if p.isdigit()]
189
+ except OSError:
190
+ return roots
191
+ for pid in pids:
192
+ try:
193
+ roots.add(os.readlink(os.path.join(proc, pid, 'cwd')))
194
+ except OSError:
195
+ continue
196
+ return roots
197
+
198
+
199
+ def in_use(checkout, roots):
200
+ prefix = checkout.rstrip('/') + '/'
201
+ return any(r == checkout or r.startswith(prefix) for r in roots)
202
+
203
+
204
+ def size_of(path):
205
+ total = 0
206
+ for current, dirs, files in os.walk(path):
207
+ for name in dirs + files:
208
+ try:
209
+ total += os.lstat(os.path.join(current, name)).st_blocks * 512
210
+ except OSError:
211
+ continue
212
+ return total
213
+
214
+
215
+ def remove(path, dry_run):
216
+ """Deletes one directory without following links; returns bytes freed."""
217
+ if not is_real_dir(path):
218
+ return 0
219
+ size = size_of(path)
220
+ if not dry_run:
221
+ shutil.rmtree(path, ignore_errors=True)
222
+ if os.path.lexists(path):
223
+ return 0
224
+ return size
225
+
226
+
227
+ def collect_checkouts(home, idle_seconds, max_depth, proc, now, dry_run):
228
+ roots = busy_roots(proc)
229
+ freed = 0
230
+ for checkout in find_checkouts(home, max_depth):
231
+ gdir = git_dir(checkout)
232
+ if gdir is None:
233
+ log(event='checkout_skipped', checkout=checkout, reason='no_git_dir')
234
+ continue
235
+ if in_use(checkout, roots) or now - git_activity(checkout, gdir) < idle_seconds:
236
+ continue
237
+ dirs = ignored(checkout, candidates(checkout))
238
+ if not dirs:
239
+ continue
240
+ latest = last_activity(checkout, gdir, dirs)
241
+ if latest is None:
242
+ log(event='checkout_skipped', checkout=checkout, reason='git_status_failed')
243
+ continue
244
+ if now - latest < idle_seconds:
245
+ continue
246
+ for path in dirs:
247
+ got = remove(path, dry_run)
248
+ freed += got
249
+ log(event='removed', path=path, mib=got // MIB, dry_run=dry_run)
250
+ return freed
251
+
252
+
253
+ def collect_cache(directory, age_seconds, now, dry_run):
254
+ """Removes top-level cache entries not written for age_seconds."""
255
+ freed = 0
256
+ try:
257
+ entries = list(os.scandir(directory))
258
+ except OSError:
259
+ return 0
260
+ for entry in entries:
261
+ if now - mtime(entry.path) < age_seconds:
262
+ continue
263
+ if entry.is_dir(follow_symlinks=False):
264
+ got = remove(entry.path, dry_run)
265
+ elif entry.is_file(follow_symlinks=False):
266
+ got = entry.stat(follow_symlinks=False).st_blocks * 512
267
+ if not dry_run:
268
+ try:
269
+ os.unlink(entry.path)
270
+ except OSError:
271
+ got = 0
272
+ else:
273
+ continue
274
+ freed += got
275
+ if freed:
276
+ log(event='cache_pruned', cache=directory, mib=freed // MIB, dry_run=dry_run)
277
+ return freed
278
+
279
+
280
+ def command(argv, dry_run, name):
281
+ if shutil.which(argv[0]) is None:
282
+ return
283
+ if dry_run:
284
+ log(event='would_run', step=name)
285
+ return
286
+ try:
287
+ out = subprocess.run(argv, capture_output=True, timeout=1800)
288
+ log(event='ran', step=name, ok=out.returncode == 0)
289
+ except (OSError, subprocess.TimeoutExpired) as exc:
290
+ log(event='ran', step=name, ok=False, error=type(exc).__name__)
291
+
292
+
293
+ def main(argv):
294
+ parser = argparse.ArgumentParser()
295
+ parser.add_argument('--home', required=True)
296
+ parser.add_argument('--path', action='append', default=[])
297
+ parser.add_argument('--idle-days', type=float, default=7)
298
+ parser.add_argument('--pressure-mib', type=int, default=15360)
299
+ parser.add_argument('--pressure-idle-hours', type=float, default=12)
300
+ parser.add_argument('--docker-age-hours', type=int, default=168)
301
+ parser.add_argument('--pressure-docker-age-hours', type=int, default=24)
302
+ parser.add_argument('--pressure-cache', action='append', default=[])
303
+ parser.add_argument('--max-depth', type=int, default=4)
304
+ parser.add_argument('--proc', default='/proc')
305
+ parser.add_argument('--home-only', action='store_true')
306
+ parser.add_argument('--dry-run', action='store_true')
307
+ args = parser.parse_args(argv)
308
+ home = os.path.realpath(args.home)
309
+ paths = args.path or ['/', home]
310
+ now = time.time()
311
+ before = {p: free_bytes(p) for p in paths}
312
+ pressure = min(before.values()) < args.pressure_mib * MIB
313
+ idle = args.pressure_idle_hours * HOUR if pressure else args.idle_days * 24 * HOUR
314
+ docker_age = args.pressure_docker_age_hours if pressure else args.docker_age_hours
315
+ log(event='started', pressure=pressure, idle_hours=idle / HOUR,
316
+ docker_age_hours=docker_age, dry_run=args.dry_run,
317
+ free_mib={p: v // MIB for p, v in before.items()})
318
+
319
+ freed = collect_checkouts(home, idle, args.max_depth, args.proc, now, args.dry_run)
320
+ if pressure:
321
+ for cache in args.pressure_cache:
322
+ freed += collect_cache(os.path.join(home, cache), idle, now, args.dry_run)
323
+ if not args.home_only:
324
+ command(['docker', 'image', 'prune', '--all', '--force', '--filter',
325
+ f'until={docker_age}h'], args.dry_run, 'docker_images')
326
+ command(['docker', 'builder', 'prune', '--all', '--force', '--filter',
327
+ f'until={docker_age}h'], args.dry_run, 'docker_build_cache')
328
+ command(['apt-get', 'clean'], args.dry_run, 'apt_cache')
329
+
330
+ after = {p: free_bytes(p) for p in paths}
331
+ log(event='finished', checkout_mib=freed // MIB, dry_run=args.dry_run,
332
+ free_mib={p: v // MIB for p, v in after.items()})
333
+
334
+
335
+ if __name__ == '__main__':
336
+ main(sys.argv[1:])