@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.
- package/README.md +265 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +10 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/README.md +86 -25
- package/ansible/monitoring/agent.yml +40 -2
- package/ansible/monitoring/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +5 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/baseline.yml +1 -1
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/policy.json +13 -3
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/ansible/uv.lock +190 -0
- package/bootstrap/bootstrap.sh +25 -18
- package/bootstrap/doctor.ts +46 -1
- package/bootstrap/fleet-manager-run.sh +42 -15
- package/bootstrap/fleet-remote-bootstrap.sh +85 -12
- package/bootstrap/global-config.ts +64 -6
- package/bootstrap/install-launcher.sh +3 -0
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- package/cliproxy/README.md +3 -3
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +29 -1
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/consumer-setup.md +408 -0
- package/docs/declared.md +91 -0
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +400 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +69 -0
- package/docs/ubuntu-sync.md +357 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +36 -0
- package/mise.lock +29 -0
- package/mise.toml +15 -2
- package/package.json +11 -2
- package/schema/fleet.schema.json +137 -15
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/fleet-verify.ts +9 -0
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +293 -22
- package/src/agents/provider-errors.ts +9 -2
- package/src/agents/registry.ts +7 -1
- package/src/cli/agent-guide.ts +39 -5
- package/src/cli/app.ts +11 -4
- package/src/cli/cli.ts +7 -0
- package/src/cli/command-index.ts +7 -0
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-checkout.ts +56 -17
- package/src/cli/fleet-sync-enrollment.ts +19 -23
- package/src/cli/fleet-sync-forward.ts +1 -1
- package/src/cli/fleet-sync-manager.ts +84 -12
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +7 -3
- package/src/cli/fleet-sync-remote.ts +31 -18
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +159 -57
- package/src/cli/fleet.ts +4 -2
- package/src/cli/managed-health.ts +4 -4
- package/src/cli/node-sync.ts +7 -0
- package/src/cli/program.ts +20 -4
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +86 -1
- package/src/cli/registry.ts +41 -1
- package/src/cli/roster.ts +46 -30
- package/src/cli/settings.ts +15 -12
- package/src/cli/source-operations.ts +27 -3
- package/src/cli/tailnet.ts +2 -2
- package/src/definition.ts +116 -4
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/machine/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +9 -18
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +15 -3
- package/src/monitoring/beszel.ts +11 -4
- package/src/monitoring/enroll.ts +2 -1
- package/src/monitoring/hub-access.ts +4 -3
- package/src/monitoring/hub-backup.ts +121 -0
- package/src/monitoring/hub-viewer.ts +8 -8
- package/src/monitoring/hub.ts +3 -3
- package/src/monitoring/manager-hub.ts +30 -14
- package/src/node-converge-linux.ts +30 -7
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/resource-verify.ts +8 -4
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/ubuntu-audit.ts +5 -2
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +17 -19
- package/src/remote-entry.ts +15 -1
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/service-auth.ts +15 -5
- package/src/state/bucket.ts +208 -0
- package/src/state/check.ts +74 -0
- package/src/state/connection.ts +92 -0
- package/src/state/enrollment.ts +45 -0
- package/src/state/legacy.ts +89 -0
- package/src/state/litestream.ts +143 -0
- package/src/state/operator.ts +20 -0
- package/src/state/paths.ts +40 -0
- package/src/state/roster.ts +62 -0
- package/src/state/store.ts +169 -0
- package/src/state/sync.ts +352 -0
- package/src/t3/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +24 -2
- package/src/toolchain-checks.ts +25 -0
- package/src/transport.ts +3 -2
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +41 -13
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
# Beszel owner and recovery
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
pinned Compose hub, native agent
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 (
|
|
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 (`
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
113
|
-
Do not provision a bucket or reuse unrelated
|
|
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
|
|
147
|
-
record,
|
|
148
|
-
|
|
149
|
-
|
|
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/
|
|
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
|
-
|
|
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;
|
|
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:])
|