@crvouga/dev-fleet 0.2.0 → 0.4.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 +50 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/manager.yml +3 -0
- package/ansible/macos/policy.json +11 -1
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/compose.yml +10 -0
- package/ansible/monitoring/container-watchdog.py +118 -0
- package/ansible/monitoring/hub-darwin.yml +146 -0
- package/ansible/net/remote-probe.sh +5 -0
- package/ansible/ubuntu/baseline.yml +2 -0
- package/ansible/ubuntu/disk-gc.py +25 -3
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +51 -1
- 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/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +4 -4
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +30 -2
- 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/authentication.md +90 -0
- package/docs/declared.md +5 -1
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +26 -15
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/topology.md +2 -0
- package/docs/ubuntu-sync.md +35 -28
- package/examples/fleet.json +19 -1
- package/llms.txt +3 -0
- package/mise.toml +10 -1
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +244 -1
- package/schema/output/check.schema.json +23 -0
- package/schema/output/error.schema.json +14 -0
- package/schema/output/fleet-report.schema.json +79 -0
- package/schema/output/fleet-sync.schema.json +112 -0
- package/schema/output/report.schema.json +38 -0
- 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/launch.ts +84 -5
- package/src/agents/native-verify.ts +128 -2
- package/src/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-manager.ts +31 -4
- package/src/cli/fleet-sync-output.ts +3 -2
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +23 -14
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +126 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +86 -0
- package/src/cli/registry.ts +26 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +226 -7
- package/src/git-identity.ts +160 -0
- 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-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +13 -17
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +44 -12
- package/src/notify.ts +239 -0
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- 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/windows.ts +0 -14
- package/src/remote-bootstrap.ts +87 -25
- package/src/remote-entry.ts +15 -1
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/service-auth.ts +15 -5
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- 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-linked-check.ts +1 -1
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/transport.ts +3 -2
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +68 -13
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
package/README.md
CHANGED
|
@@ -98,7 +98,11 @@ Ansible policy and verifies the result. It prints one table and writes a full
|
|
|
98
98
|
JSON report. The first run on a machine installs everything, mise included, so
|
|
99
99
|
Git is the only tool it needs beforehand.
|
|
100
100
|
|
|
101
|
-
`--dry-run` changes no machine.
|
|
101
|
+
`--dry-run` changes no machine. It first brings the manager's copy of the
|
|
102
|
+
fleet state up to the bucket (never uploading), then prints, and adds to
|
|
103
|
+
`--json` as `plan`, what a sync would change: the revision, each tool pin that
|
|
104
|
+
moves, and what restarts because of it (a T3 restart ends its running agent
|
|
105
|
+
turns). On the manager it checks the release out into
|
|
102
106
|
dev-fleet's cache and installs the Bun that release pins beside any others, so
|
|
103
107
|
that it can run; it never installs the toolchain, publishes the global mise
|
|
104
108
|
configuration, edits shell profiles or touches a worker.
|
|
@@ -189,6 +193,14 @@ warns that the manager holds the only one. [docs/state.md](docs/state.md)
|
|
|
189
193
|
explains how replication stays correct, how to move an existing fleet to a
|
|
190
194
|
project, and which state is not in the bucket yet.
|
|
191
195
|
|
|
196
|
+
## Provider accounts
|
|
197
|
+
|
|
198
|
+
Provider accounts live only on the CLI proxy. `npx devfleet fleet-accounts`
|
|
199
|
+
lists them with their state; `--add`, `--relogin` and `--remove` change one
|
|
200
|
+
and propagate the result to every node, and `fleet-sync --only accounts`
|
|
201
|
+
propagates them without a full sync.
|
|
202
|
+
[docs/accounts.md](docs/accounts.md) explains how.
|
|
203
|
+
|
|
192
204
|
## Add or remove a machine
|
|
193
205
|
|
|
194
206
|
1. Set up SSH from the manager to the new Ubuntu box (a non-root user with
|
|
@@ -233,6 +245,26 @@ package carries: `fleet-state --example` prints a complete example that points
|
|
|
233
245
|
| `workers` | The Ubuntu workers |
|
|
234
246
|
| `services` | The proxy's public origin and the monitoring dashboard and accounts |
|
|
235
247
|
| `worker` | Policy every worker must meet |
|
|
248
|
+
| `ides` | The IDEs every machine runs; `["t3"]` when absent |
|
|
249
|
+
| `t3` | T3 settings every machine holds (`profile`) and `requiredModels` |
|
|
250
|
+
|
|
251
|
+
### Supported IDEs
|
|
252
|
+
|
|
253
|
+
Every supported IDE is reached through one interface (`src/ide/ide.ts`): its
|
|
254
|
+
settings on each machine, the manager's own copy (pinned version and policy)
|
|
255
|
+
and what the manager measures of each worker. Enabling one is `ides` in
|
|
256
|
+
`fleet.json`.
|
|
257
|
+
|
|
258
|
+
| IDE | `ides` | Manager | Workers |
|
|
259
|
+
| ------- | ------ | ----------------------------------------------------- | ------------------------------------------ |
|
|
260
|
+
| T3 Code | `t3` | Pinned CLI, policy with updates off, version measured | Pinned desktop app, locked policy, pairing |
|
|
261
|
+
|
|
262
|
+
Under `t3.profile`, `fleet.json` names top-level T3 settings every machine
|
|
263
|
+
holds the same, such as tool servers. A setting not named there stays the
|
|
264
|
+
machine's own, and one edited on a machine since `fleet-sync` last wrote it
|
|
265
|
+
is reported as `T3_PROFILE_CONFLICT` by the check `t3:profile`, never
|
|
266
|
+
overwritten. The check `t3:models` reports a model T3 offers that the proxy
|
|
267
|
+
does not serve, and a model in `t3.requiredModels` that is missing.
|
|
236
268
|
|
|
237
269
|
Secrets are asked for by purpose (`proxy-client-key`,
|
|
238
270
|
`monitoring-account-password`, `monitoring-tunnel-token`,
|
|
@@ -288,9 +320,11 @@ fleet.json contents.
|
|
|
288
320
|
`fleet-sync` fetches that copy again when it is deleted, but the proxy then
|
|
289
321
|
needs `devfleet cliproxy install` once more.
|
|
290
322
|
- The manager is a Mac host. There is no container image of the manager.
|
|
291
|
-
- `fleet-sync --schedule enable` (unattended runs
|
|
292
|
-
|
|
293
|
-
|
|
323
|
+
- `fleet-sync --schedule enable --apply --yes` (unattended runs every 30
|
|
324
|
+
minutes) is installed by that command on the manager, not yet by the
|
|
325
|
+
manager's playbook, and its interval is not yet part of `fleet.json`.
|
|
326
|
+
`fleet-sync --schedule status` lists its last runs, and `fleet-status` fails
|
|
327
|
+
`manager:schedule` when it has not converged the fleet for two intervals.
|
|
294
328
|
- Not everything on a machine is pinned. [What is declared, and
|
|
295
329
|
where](docs/declared.md) lists what is and what is not: most of a worker's
|
|
296
330
|
own packages, the manager's applications and every login are outside it.
|
|
@@ -299,18 +333,18 @@ fleet.json contents.
|
|
|
299
333
|
|
|
300
334
|
## Repository layout
|
|
301
335
|
|
|
302
|
-
| Path | What
|
|
303
|
-
| ------------ |
|
|
304
|
-
| `src/` | Inventory, orchestration, verification, the report; `src/cli` CLI
|
|
305
|
-
| `src/state/` | The fleet state database and its replication to the bucket
|
|
306
|
-
| `ansible/` | Machine policy: `ubuntu/`, `macos/`, `monitoring/`
|
|
307
|
-
| `agents/` | Agent launchers T3 runs, and their registry
|
|
308
|
-
| `cliproxy/` | The CLIProxyAPI service: install, audit, backup, model health
|
|
309
|
-
| `bootstrap/` | Toolchain bootstrap and the manager and worker entry scripts
|
|
310
|
-
| `schema/` | JSON schemas for `fleet.json
|
|
311
|
-
| `examples/` | A complete example configuration
|
|
312
|
-
| `features/` | What dev-fleet does, as Gherkin features: the tests
|
|
313
|
-
| `bin/` | The entry point of the published package
|
|
336
|
+
| Path | What |
|
|
337
|
+
| ------------ | ----------------------------------------------------------------------- |
|
|
338
|
+
| `src/` | Inventory, orchestration, verification, the report; `src/cli` CLI |
|
|
339
|
+
| `src/state/` | The fleet state database and its replication to the bucket |
|
|
340
|
+
| `ansible/` | Machine policy: `ubuntu/`, `macos/`, `monitoring/` |
|
|
341
|
+
| `agents/` | Agent launchers T3 runs, and their registry |
|
|
342
|
+
| `cliproxy/` | The CLIProxyAPI service: install, audit, backup, model health |
|
|
343
|
+
| `bootstrap/` | Toolchain bootstrap and the manager and worker entry scripts |
|
|
344
|
+
| `schema/` | JSON schemas for `fleet.json`, the roster and command output; check IDs |
|
|
345
|
+
| `examples/` | A complete example configuration |
|
|
346
|
+
| `features/` | What dev-fleet does, as Gherkin features: the tests |
|
|
347
|
+
| `bin/` | The entry point of the published package |
|
|
314
348
|
|
|
315
349
|
## Develop
|
|
316
350
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
# The Git identity and GitHub credentials every node commits and pushes with,
|
|
3
|
+
# the manager included, as the developer: no become. Written in Git's own
|
|
4
|
+
# per-user file (~/.config/git/config), inside a marked block, so a person's
|
|
5
|
+
# ~/.gitconfig is never touched. Variables arrive through a private 0600
|
|
6
|
+
# variables file, never argv.
|
|
7
|
+
- name: Converge the Git identity and GitHub credential helper
|
|
8
|
+
hosts: localhost
|
|
9
|
+
connection: local
|
|
10
|
+
become: false
|
|
11
|
+
gather_facts: false
|
|
12
|
+
vars:
|
|
13
|
+
git_config: '{{ git_home }}/.config/git/config'
|
|
14
|
+
# gh answers for GitHub through the pinned toolchain's shim, so any
|
|
15
|
+
# session's PATH works.
|
|
16
|
+
git_credential_helper: "!'{{ git_home }}/.local/share/mise/shims/gh' auth git-credential"
|
|
17
|
+
tasks:
|
|
18
|
+
- name: Require a declared identity
|
|
19
|
+
ansible.builtin.assert:
|
|
20
|
+
that:
|
|
21
|
+
- git_name | length > 0
|
|
22
|
+
- git_email is match('^[^@ ]+@[^@ ]+$')
|
|
23
|
+
- git_home is match('^/')
|
|
24
|
+
fail_msg: GIT_IDENTITY_UNDECLARED; declare git.name and git.email in fleet.json.
|
|
25
|
+
- name: Create the per-user Git configuration directory
|
|
26
|
+
ansible.builtin.file:
|
|
27
|
+
path: '{{ git_home }}/.config/git'
|
|
28
|
+
state: directory
|
|
29
|
+
mode: '0700'
|
|
30
|
+
- name: Declare the identity and the GitHub credential helper
|
|
31
|
+
ansible.builtin.blockinfile:
|
|
32
|
+
path: '{{ git_config }}'
|
|
33
|
+
create: true
|
|
34
|
+
mode: '0600'
|
|
35
|
+
marker: '# {mark} dev-fleet: Git identity and GitHub credentials'
|
|
36
|
+
block: |
|
|
37
|
+
[user]
|
|
38
|
+
name = "{{ git_name }}"
|
|
39
|
+
email = {{ git_email }}
|
|
40
|
+
[credential "https://github.com"]
|
|
41
|
+
helper =
|
|
42
|
+
helper = {{ git_credential_helper }}
|
|
43
|
+
[credential "https://gist.github.com"]
|
|
44
|
+
helper =
|
|
45
|
+
helper = {{ git_credential_helper }}
|
|
46
|
+
- name: Read the effective identity and GitHub credential helper
|
|
47
|
+
ansible.builtin.command:
|
|
48
|
+
argv: [git, config, --global, --get-all, '{{ item }}']
|
|
49
|
+
environment:
|
|
50
|
+
HOME: '{{ git_home }}'
|
|
51
|
+
loop:
|
|
52
|
+
- user.name
|
|
53
|
+
- user.email
|
|
54
|
+
- credential.https://github.com.helper
|
|
55
|
+
register: git_effective
|
|
56
|
+
check_mode: false
|
|
57
|
+
changed_when: false
|
|
58
|
+
failed_when: false
|
|
59
|
+
- name: Require the declared identity and helper to be the effective ones (worker:git-identity)
|
|
60
|
+
ansible.builtin.assert:
|
|
61
|
+
that:
|
|
62
|
+
- git_effective.results[0].stdout_lines | default([]) | last | default('') == git_name
|
|
63
|
+
- git_effective.results[1].stdout_lines | default([]) | last | default('') == git_email
|
|
64
|
+
- git_effective.results[2].stdout_lines | default([]) | last | default('') == git_credential_helper
|
|
65
|
+
fail_msg: GIT_IDENTITY_OVERRIDDEN; another Git configuration (often ~/.gitconfig) sets user.name, user.email or the GitHub credential helper after dev-fleet's. Remove those lines.
|
|
66
|
+
when: not ansible_check_mode
|
|
@@ -5,5 +5,15 @@
|
|
|
5
5
|
"sleep": "0",
|
|
6
6
|
"womp": "1"
|
|
7
7
|
},
|
|
8
|
-
"beszelDaemon": "dev.devfleet.beszel-agent"
|
|
8
|
+
"beszelDaemon": "dev.devfleet.beszel-agent",
|
|
9
|
+
"disk": {
|
|
10
|
+
"warnFreeMiB": 20480,
|
|
11
|
+
"failFreeMiB": 5120,
|
|
12
|
+
"dockerWarnGiB": 64
|
|
13
|
+
},
|
|
14
|
+
"containerWatchdog": {
|
|
15
|
+
"unhealthyChecks": 3,
|
|
16
|
+
"maxRestartsPerHour": 3,
|
|
17
|
+
"intervalSeconds": 60
|
|
18
|
+
}
|
|
9
19
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
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
|
|
5
|
+
# by fleet-sync on every run and stay unlocked here: locking them needs an
|
|
6
|
+
# 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
|
+
- name: Stage the fleet's T3 policy on the manager
|
|
9
|
+
hosts: localhost
|
|
10
|
+
connection: local
|
|
11
|
+
become: true
|
|
12
|
+
gather_facts: false
|
|
13
|
+
vars:
|
|
14
|
+
t3_policy_directory: /Library/Application Support/T3Code
|
|
15
|
+
tasks:
|
|
16
|
+
- name: Inspect existing administrator policy
|
|
17
|
+
ansible.builtin.stat:
|
|
18
|
+
path: '{{ t3_policy_directory }}/policy.json'
|
|
19
|
+
register: policy_file
|
|
20
|
+
- name: Inspect Fleet ownership marker
|
|
21
|
+
ansible.builtin.stat:
|
|
22
|
+
path: '{{ t3_policy_directory }}/.personal-fleet-policy'
|
|
23
|
+
register: policy_owner
|
|
24
|
+
- name: Refuse to overwrite an unrelated administrator policy
|
|
25
|
+
ansible.builtin.assert:
|
|
26
|
+
that: not policy_file.stat.exists or policy_owner.stat.exists
|
|
27
|
+
fail_msg: T3_POLICY_OWNER_CONFLICT; review and merge the existing administrator policy in Git before adoption.
|
|
28
|
+
- name: Create administrator-owned policy directory
|
|
29
|
+
ansible.builtin.file:
|
|
30
|
+
path: '{{ t3_policy_directory }}'
|
|
31
|
+
state: directory
|
|
32
|
+
owner: root
|
|
33
|
+
group: wheel
|
|
34
|
+
mode: '0755'
|
|
35
|
+
- name: Record policy ownership before installation
|
|
36
|
+
ansible.builtin.copy:
|
|
37
|
+
dest: '{{ t3_policy_directory }}/.personal-fleet-policy'
|
|
38
|
+
content: "ansible/ubuntu/t3/policy.json\n"
|
|
39
|
+
owner: root
|
|
40
|
+
group: wheel
|
|
41
|
+
mode: '0644'
|
|
42
|
+
- name: Install the fleet's policy with backup
|
|
43
|
+
ansible.builtin.copy:
|
|
44
|
+
content: "{{ lookup('ansible.builtin.file', '../ubuntu/t3/policy.json') | from_json | to_nice_json }}"
|
|
45
|
+
dest: '{{ t3_policy_directory }}/policy.json'
|
|
46
|
+
owner: root
|
|
47
|
+
group: wheel
|
|
48
|
+
mode: '0644'
|
|
49
|
+
backup: true
|
|
@@ -5,6 +5,16 @@ services:
|
|
|
5
5
|
hub:
|
|
6
6
|
image: henrygd/beszel:0.21.1@sha256:8514a69d9bbbcb74f371a3205561ee231fc0289739cea64e8131f341d695b770
|
|
7
7
|
restart: unless-stopped
|
|
8
|
+
# Docker restarts a hub that exits; the container watchdog on the manager
|
|
9
|
+
# restarts one that stays unhealthy (container-watchdog.py).
|
|
10
|
+
labels:
|
|
11
|
+
dev.devfleet.watchdog: restart
|
|
12
|
+
healthcheck:
|
|
13
|
+
test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
|
|
14
|
+
interval: 30s
|
|
15
|
+
timeout: 5s
|
|
16
|
+
retries: 3
|
|
17
|
+
start_period: 30s
|
|
8
18
|
ports:
|
|
9
19
|
- '127.0.0.1:8090:8090'
|
|
10
20
|
environment:
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""Restart a fleet container that stays unhealthy, a bounded number of times.
|
|
3
|
+
|
|
4
|
+
Managed by fleet-sync (ansible/monitoring/hub-darwin.yml) as the per-user
|
|
5
|
+
launchd agent dev.devfleet.container-watchdog on the manager. Docker restarts
|
|
6
|
+
a container that exits, never one that hangs while it still holds its port.
|
|
7
|
+
Every run reads the health of each container labelled
|
|
8
|
+
dev.devfleet.watchdog=restart. One that was unhealthy for --unhealthy-checks
|
|
9
|
+
runs in a row is restarted, at most --max-restarts-per-hour times an hour.
|
|
10
|
+
Past that cap it is left alone and recorded as capped: a restart storm is a
|
|
11
|
+
failing check (manager:container-watchdog), not a loop.
|
|
12
|
+
|
|
13
|
+
Every decision is one JSON line on stdout; nothing a container printed is
|
|
14
|
+
repeated. The state file holds counters only and is safe to delete.
|
|
15
|
+
|
|
16
|
+
Usage: container-watchdog.py --state FILE [--unhealthy-checks N]
|
|
17
|
+
[--max-restarts-per-hour N] [--dry-run]
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
HOUR = 3600
|
|
27
|
+
LABEL = 'dev.devfleet.watchdog=restart'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def log(**event):
|
|
31
|
+
print(json.dumps(event, sort_keys=True), flush=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decide(memory, health, now, limit, cap):
|
|
35
|
+
"""The next memory, and the containers to restart now with the reason.
|
|
36
|
+
|
|
37
|
+
memory: {name: {"unhealthy": n, "restarts": [t, ...], "capped": t|None}}
|
|
38
|
+
health: {name: "healthy" | "unhealthy" | "starting" | "none"}
|
|
39
|
+
"""
|
|
40
|
+
after, restart = {}, []
|
|
41
|
+
for name, status in sorted(health.items()):
|
|
42
|
+
before = memory.get(name, {})
|
|
43
|
+
restarts = [t for t in before.get('restarts', []) if now - t < HOUR]
|
|
44
|
+
unhealthy = before.get('unhealthy', 0) + 1 if status == 'unhealthy' else 0
|
|
45
|
+
capped = before.get('capped')
|
|
46
|
+
if capped is not None and now - capped >= HOUR:
|
|
47
|
+
capped = None
|
|
48
|
+
if unhealthy >= limit:
|
|
49
|
+
if len(restarts) < cap:
|
|
50
|
+
restarts.append(now)
|
|
51
|
+
restart.append((name, f'unhealthy for {unhealthy} checks'))
|
|
52
|
+
unhealthy = 0
|
|
53
|
+
else:
|
|
54
|
+
capped = now
|
|
55
|
+
after[name] = {'unhealthy': unhealthy, 'restarts': restarts,
|
|
56
|
+
'capped': capped}
|
|
57
|
+
return after, restart
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def docker(*args):
|
|
61
|
+
return subprocess.run(['docker', *args], capture_output=True, text=True,
|
|
62
|
+
timeout=60)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def observe():
|
|
66
|
+
listed = docker('ps', '--filter', f'label={LABEL}', '--format', '{{.Names}}')
|
|
67
|
+
if listed.returncode:
|
|
68
|
+
return None
|
|
69
|
+
health = {}
|
|
70
|
+
for name in listed.stdout.split():
|
|
71
|
+
state = docker('inspect', '--format',
|
|
72
|
+
'{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}',
|
|
73
|
+
name)
|
|
74
|
+
health[name] = state.stdout.strip() if state.returncode == 0 else 'none'
|
|
75
|
+
return health
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv):
|
|
79
|
+
parser = argparse.ArgumentParser()
|
|
80
|
+
parser.add_argument('--state', required=True)
|
|
81
|
+
parser.add_argument('--unhealthy-checks', type=int, default=3)
|
|
82
|
+
parser.add_argument('--max-restarts-per-hour', type=int, default=3)
|
|
83
|
+
parser.add_argument('--dry-run', action='store_true')
|
|
84
|
+
args = parser.parse_args(argv)
|
|
85
|
+
try:
|
|
86
|
+
with open(args.state) as file:
|
|
87
|
+
memory = json.load(file)
|
|
88
|
+
if not isinstance(memory, dict):
|
|
89
|
+
memory = {}
|
|
90
|
+
except (OSError, ValueError):
|
|
91
|
+
memory = {}
|
|
92
|
+
health = observe()
|
|
93
|
+
if health is None:
|
|
94
|
+
log(event='docker_unavailable')
|
|
95
|
+
return 0
|
|
96
|
+
now = time.time()
|
|
97
|
+
memory, restart = decide(memory, health, now, args.unhealthy_checks,
|
|
98
|
+
args.max_restarts_per_hour)
|
|
99
|
+
for name, reason in restart:
|
|
100
|
+
ok = True
|
|
101
|
+
if not args.dry_run:
|
|
102
|
+
ok = docker('restart', name).returncode == 0
|
|
103
|
+
log(event='restarted', container=name, reason=reason, ok=ok,
|
|
104
|
+
dry_run=args.dry_run)
|
|
105
|
+
for name, entry in memory.items():
|
|
106
|
+
if entry['capped'] == now:
|
|
107
|
+
log(event='capped', container=name,
|
|
108
|
+
restarts=len(entry['restarts']))
|
|
109
|
+
os.makedirs(os.path.dirname(os.path.abspath(args.state)), exist_ok=True)
|
|
110
|
+
temporary = args.state + '.tmp'
|
|
111
|
+
with open(temporary, 'w') as file:
|
|
112
|
+
json.dump(memory, file, sort_keys=True)
|
|
113
|
+
os.replace(temporary, args.state)
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == '__main__':
|
|
118
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
- compose
|
|
15
15
|
- '-f'
|
|
16
16
|
- '{{ playbook_dir }}/compose.yml'
|
|
17
|
+
watchdog_policy: "{{ (lookup('ansible.builtin.file', playbook_dir ~ '/../macos/policy.json') | from_json).containerWatchdog }}"
|
|
18
|
+
watchdog_label: dev.devfleet.container-watchdog
|
|
19
|
+
watchdog_root: '{{ ansible_facts.env.HOME }}/Library/Application Support/dev-fleet/container-watchdog'
|
|
20
|
+
watchdog_state: '{{ ansible_facts.env.HOME }}/Library/Caches/dev-fleet/container-watchdog.json'
|
|
21
|
+
watchdog_plist: '{{ ansible_facts.env.HOME }}/Library/LaunchAgents/{{ watchdog_label }}.plist'
|
|
22
|
+
watchdog_domain: 'gui/{{ ansible_facts.user_uid }}'
|
|
17
23
|
beszel_environment:
|
|
18
24
|
BESZEL_HUB_URL: '{{ beszel_hub_url }}'
|
|
19
25
|
BESZEL_APP_URL: '{{ beszel_public_url | default(beszel_hub_url) }}'
|
|
@@ -100,3 +106,143 @@
|
|
|
100
106
|
delay: 2
|
|
101
107
|
register: hub_health
|
|
102
108
|
until: hub_health.status == 200
|
|
109
|
+
# Scheduled off-machine backups, as the fleet configuration declares them
|
|
110
|
+
# (services.monitoring.backups), through the hub's own superuser API. Only
|
|
111
|
+
# what differs is changed; the storage keys never leave the variables file.
|
|
112
|
+
- name: Converge the hub's scheduled off-machine backups
|
|
113
|
+
when:
|
|
114
|
+
- beszel_backup_schedule is defined
|
|
115
|
+
- beszel_fleet_password | default('') | length > 0
|
|
116
|
+
block:
|
|
117
|
+
- name: Sign in to the hub as its superuser
|
|
118
|
+
ansible.builtin.uri:
|
|
119
|
+
url: http://127.0.0.1:8090/api/collections/_superusers/auth-with-password
|
|
120
|
+
method: POST
|
|
121
|
+
body_format: json
|
|
122
|
+
body:
|
|
123
|
+
identity: '{{ beszel_fleet_identity }}'
|
|
124
|
+
password: '{{ beszel_fleet_password }}'
|
|
125
|
+
return_content: true
|
|
126
|
+
check_mode: false
|
|
127
|
+
no_log: true
|
|
128
|
+
register: hub_superuser
|
|
129
|
+
- name: Read the hub's backup settings
|
|
130
|
+
ansible.builtin.uri:
|
|
131
|
+
url: http://127.0.0.1:8090/api/settings
|
|
132
|
+
headers:
|
|
133
|
+
Authorization: '{{ hub_superuser.json.token }}'
|
|
134
|
+
return_content: true
|
|
135
|
+
check_mode: false
|
|
136
|
+
no_log: true
|
|
137
|
+
register: hub_settings
|
|
138
|
+
- name: Schedule backups to the declared off-machine storage (BESZEL_BACKUP_CONVERGENCE_FAILED)
|
|
139
|
+
ansible.builtin.uri:
|
|
140
|
+
url: http://127.0.0.1:8090/api/settings
|
|
141
|
+
method: PATCH
|
|
142
|
+
headers:
|
|
143
|
+
Authorization: '{{ hub_superuser.json.token }}'
|
|
144
|
+
body_format: json
|
|
145
|
+
body:
|
|
146
|
+
backups:
|
|
147
|
+
cron: '{{ beszel_backup_schedule }}'
|
|
148
|
+
cronMaxKeep: '{{ beszel_backup_keep | int }}'
|
|
149
|
+
s3:
|
|
150
|
+
enabled: true
|
|
151
|
+
bucket: '{{ beszel_backup_bucket }}'
|
|
152
|
+
region: '{{ beszel_backup_region }}'
|
|
153
|
+
endpoint: '{{ beszel_backup_endpoint }}'
|
|
154
|
+
accessKey: '{{ beszel_backup_access_key }}'
|
|
155
|
+
secret: '{{ beszel_backup_secret_key }}'
|
|
156
|
+
forcePathStyle: '{{ beszel_backup_force_path_style | default(false) | bool }}'
|
|
157
|
+
no_log: true
|
|
158
|
+
changed_when: true
|
|
159
|
+
when: >-
|
|
160
|
+
(hub_settings.json.backups | default({})).cron | default('') != beszel_backup_schedule
|
|
161
|
+
or (hub_settings.json.backups | default({})).cronMaxKeep | default(0) | int != beszel_backup_keep | int
|
|
162
|
+
or not ((hub_settings.json.backups | default({})).s3 | default({})).enabled | default(false)
|
|
163
|
+
or ((hub_settings.json.backups | default({})).s3 | default({})).bucket | default('') != beszel_backup_bucket
|
|
164
|
+
or ((hub_settings.json.backups | default({})).s3 | default({})).region | default('') != beszel_backup_region
|
|
165
|
+
or ((hub_settings.json.backups | default({})).s3 | default({})).endpoint | default('') != beszel_backup_endpoint
|
|
166
|
+
or ((hub_settings.json.backups | default({})).s3 | default({})).accessKey | default('') != beszel_backup_access_key
|
|
167
|
+
or ((hub_settings.json.backups | default({})).s3 | default({})).forcePathStyle | default(false) != beszel_backup_force_path_style | default(false) | bool
|
|
168
|
+
# Docker restarts a container that exits, never one that hangs while it
|
|
169
|
+
# holds its port. The watchdog restarts a labelled container (the hub, the
|
|
170
|
+
# CLI proxy) that stays unhealthy, a bounded number of times an hour; the
|
|
171
|
+
# bounds are containerWatchdog in ansible/macos/policy.json, and
|
|
172
|
+
# manager:container-watchdog reports a container past them.
|
|
173
|
+
- name: Create the container watchdog's directories
|
|
174
|
+
ansible.builtin.file:
|
|
175
|
+
path: '{{ item }}'
|
|
176
|
+
state: directory
|
|
177
|
+
mode: '0700'
|
|
178
|
+
loop:
|
|
179
|
+
- '{{ watchdog_root }}'
|
|
180
|
+
- '{{ watchdog_state | dirname }}'
|
|
181
|
+
- '{{ ansible_facts.env.HOME }}/Library/LaunchAgents'
|
|
182
|
+
- name: Install the container watchdog
|
|
183
|
+
ansible.builtin.copy:
|
|
184
|
+
src: '{{ playbook_dir }}/container-watchdog.py'
|
|
185
|
+
dest: '{{ watchdog_root }}/container-watchdog.py'
|
|
186
|
+
mode: '0700'
|
|
187
|
+
- name: Install the container watchdog's launch agent
|
|
188
|
+
ansible.builtin.copy:
|
|
189
|
+
dest: '{{ watchdog_plist }}'
|
|
190
|
+
mode: '0644'
|
|
191
|
+
content: |
|
|
192
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
193
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
194
|
+
<!-- Managed by personal Fleet: container watchdog -->
|
|
195
|
+
<plist version="1.0"><dict>
|
|
196
|
+
<key>Label</key><string>{{ watchdog_label }}</string>
|
|
197
|
+
<key>ProgramArguments</key><array>
|
|
198
|
+
<string>/usr/bin/python3</string>
|
|
199
|
+
<string>-I</string>
|
|
200
|
+
<string>-B</string>
|
|
201
|
+
<string>{{ watchdog_root }}/container-watchdog.py</string>
|
|
202
|
+
<string>--state</string>
|
|
203
|
+
<string>{{ watchdog_state }}</string>
|
|
204
|
+
<string>--unhealthy-checks</string>
|
|
205
|
+
<string>{{ watchdog_policy.unhealthyChecks }}</string>
|
|
206
|
+
<string>--max-restarts-per-hour</string>
|
|
207
|
+
<string>{{ watchdog_policy.maxRestartsPerHour }}</string>
|
|
208
|
+
</array>
|
|
209
|
+
<key>EnvironmentVariables</key><dict>
|
|
210
|
+
<key>PATH</key><string>/usr/local/bin:/usr/bin:/bin:/Applications/Docker.app/Contents/Resources/bin</string>
|
|
211
|
+
</dict>
|
|
212
|
+
<key>StartInterval</key><integer>{{ watchdog_policy.intervalSeconds }}</integer>
|
|
213
|
+
<key>ProcessType</key><string>Background</string>
|
|
214
|
+
<key>Umask</key><integer>63</integer>
|
|
215
|
+
<key>StandardOutPath</key><string>{{ watchdog_root }}/watchdog.log</string>
|
|
216
|
+
<key>StandardErrorPath</key><string>{{ watchdog_root }}/watchdog.log</string>
|
|
217
|
+
</dict></plist>
|
|
218
|
+
register: watchdog_agent
|
|
219
|
+
- name: Inspect whether the container watchdog is loaded
|
|
220
|
+
ansible.builtin.command:
|
|
221
|
+
argv:
|
|
222
|
+
[/bin/launchctl, print, '{{ watchdog_domain }}/{{ watchdog_label }}']
|
|
223
|
+
changed_when: false
|
|
224
|
+
failed_when: false
|
|
225
|
+
register: watchdog_loaded
|
|
226
|
+
# launchd caches a loaded definition: a changed plist needs a reload.
|
|
227
|
+
- name: Unload a stale container watchdog definition
|
|
228
|
+
ansible.builtin.command:
|
|
229
|
+
argv:
|
|
230
|
+
[
|
|
231
|
+
/bin/launchctl,
|
|
232
|
+
bootout,
|
|
233
|
+
'{{ watchdog_domain }}/{{ watchdog_label }}',
|
|
234
|
+
]
|
|
235
|
+
when: watchdog_agent.changed and watchdog_loaded.rc == 0
|
|
236
|
+
failed_when: false
|
|
237
|
+
changed_when: true
|
|
238
|
+
- name: Load the container watchdog
|
|
239
|
+
ansible.builtin.command:
|
|
240
|
+
argv:
|
|
241
|
+
[
|
|
242
|
+
/bin/launchctl,
|
|
243
|
+
bootstrap,
|
|
244
|
+
'{{ watchdog_domain }}',
|
|
245
|
+
'{{ watchdog_plist }}',
|
|
246
|
+
]
|
|
247
|
+
when: watchdog_agent.changed or watchdog_loaded.rc != 0
|
|
248
|
+
changed_when: true
|
|
@@ -8,6 +8,11 @@ if [ "$(uname)" = Darwin ]; then
|
|
|
8
8
|
else
|
|
9
9
|
echo "ncpu=$(nproc)"
|
|
10
10
|
echo "load=$(cut -d' ' -f1 /proc/loadavg)"
|
|
11
|
+
# Free inodes as a percentage: bytes can be free while every write fails.
|
|
12
|
+
df -Pi "$HOME" | awk 'NR==2 && $2>0{print "inodefree=" int($4*100/$2)}'
|
|
13
|
+
# Clock synchronisation, as systemd sees it: 1 or 0.
|
|
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}'
|
|
11
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
|
|
12
16
|
fi
|
|
13
17
|
echo "diskmb=$(df -Pk "$HOME" | awk 'NR==2{print int($4/1024)}')"
|
|
18
|
+
echo "epoch=$(date +%s)"
|
|
@@ -81,6 +81,8 @@
|
|
|
81
81
|
tasks:
|
|
82
82
|
- ansible.builtin.import_tasks: tasks/packages.yml
|
|
83
83
|
- ansible.builtin.import_tasks: tasks/docker.yml
|
|
84
|
+
- ansible.builtin.import_tasks: tasks/browser.yml
|
|
85
|
+
when: "'web' in worker_tool_profiles | default([])"
|
|
84
86
|
- ansible.builtin.import_tasks: tasks/services.yml
|
|
85
87
|
- ansible.builtin.import_tasks: tasks/power.yml
|
|
86
88
|
- ansible.builtin.import_tasks: tasks/user.yml
|
|
@@ -12,7 +12,8 @@ What it deletes:
|
|
|
12
12
|
* Docker images and build cache that no container uses and that are older
|
|
13
13
|
than --docker-age-hours (never containers or volumes);
|
|
14
14
|
* the apt package cache.
|
|
15
|
-
When a watched filesystem has less than --pressure-mib free,
|
|
15
|
+
When a watched filesystem has less than --pressure-mib free, or less than
|
|
16
|
+
--pressure-inode-percent of its inodes free, idle checkouts
|
|
16
17
|
qualify after --pressure-idle-hours, Docker leftovers after
|
|
17
18
|
--pressure-docker-age-hours, and aged entries of each --pressure-cache
|
|
18
19
|
directory are removed too.
|
|
@@ -21,7 +22,7 @@ It never deletes a checkout, a tracked file, uncommitted work, a container or
|
|
|
21
22
|
a volume. Every decision is one JSON line on stdout.
|
|
22
23
|
|
|
23
24
|
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-inode-percent N] [--pressure-idle-hours N] [--docker-age-hours N]
|
|
25
26
|
[--pressure-docker-age-hours N] [--pressure-cache DIR ...]
|
|
26
27
|
[--max-depth N] [--proc DIR] [--home-only] [--dry-run]
|
|
27
28
|
|
|
@@ -61,6 +62,25 @@ def free_bytes(path):
|
|
|
61
62
|
return st.f_bavail * st.f_frsize
|
|
62
63
|
|
|
63
64
|
|
|
65
|
+
def free_inodes_percent(path):
|
|
66
|
+
"""Free inodes as a percentage; None on a filesystem without a fixed count."""
|
|
67
|
+
st = os.statvfs(path)
|
|
68
|
+
if st.f_files <= 0:
|
|
69
|
+
return None
|
|
70
|
+
return st.f_favail * 100 / st.f_files
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def under_pressure(paths, pressure_bytes, inode_percent):
|
|
74
|
+
"""Any watched filesystem short of free bytes or of free inodes."""
|
|
75
|
+
for path in paths:
|
|
76
|
+
if free_bytes(path) < pressure_bytes:
|
|
77
|
+
return True
|
|
78
|
+
inodes = free_inodes_percent(path)
|
|
79
|
+
if inodes is not None and inodes < inode_percent:
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
64
84
|
def mtime(path):
|
|
65
85
|
try:
|
|
66
86
|
return os.lstat(path).st_mtime
|
|
@@ -296,6 +316,7 @@ def main(argv):
|
|
|
296
316
|
parser.add_argument('--path', action='append', default=[])
|
|
297
317
|
parser.add_argument('--idle-days', type=float, default=7)
|
|
298
318
|
parser.add_argument('--pressure-mib', type=int, default=15360)
|
|
319
|
+
parser.add_argument('--pressure-inode-percent', type=float, default=0)
|
|
299
320
|
parser.add_argument('--pressure-idle-hours', type=float, default=12)
|
|
300
321
|
parser.add_argument('--docker-age-hours', type=int, default=168)
|
|
301
322
|
parser.add_argument('--pressure-docker-age-hours', type=int, default=24)
|
|
@@ -309,7 +330,8 @@ def main(argv):
|
|
|
309
330
|
paths = args.path or ['/', home]
|
|
310
331
|
now = time.time()
|
|
311
332
|
before = {p: free_bytes(p) for p in paths}
|
|
312
|
-
pressure =
|
|
333
|
+
pressure = under_pressure(paths, args.pressure_mib * MIB,
|
|
334
|
+
args.pressure_inode_percent)
|
|
313
335
|
idle = args.pressure_idle_hours * HOUR if pressure else args.idle_days * 24 * HOUR
|
|
314
336
|
docker_age = args.pressure_docker_age_hours if pressure else args.docker_age_hours
|
|
315
337
|
log(event='started', pressure=pressure, idle_hours=idle / HOUR,
|