@crvouga/dev-fleet 0.3.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.
Files changed (77) hide show
  1. package/README.md +22 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/policy.json +11 -1
  4. package/ansible/monitoring/compose.yml +10 -0
  5. package/ansible/monitoring/container-watchdog.py +118 -0
  6. package/ansible/monitoring/hub-darwin.yml +146 -0
  7. package/ansible/net/remote-probe.sh +5 -0
  8. package/ansible/ubuntu/baseline.yml +2 -0
  9. package/ansible/ubuntu/disk-gc.py +25 -3
  10. package/ansible/ubuntu/disk-guard.py +24 -5
  11. package/ansible/ubuntu/policy.json +50 -1
  12. package/ansible/ubuntu/tasks/browser.yml +56 -0
  13. package/ansible/ubuntu/tasks/docker.yml +52 -0
  14. package/ansible/ubuntu/tasks/resources.yml +2 -2
  15. package/ansible/ubuntu/tasks/services.yml +13 -0
  16. package/bootstrap/doctor.ts +32 -6
  17. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  18. package/bootstrap/setup.ts +23 -8
  19. package/bootstrap/worker-tools.ts +118 -0
  20. package/cliproxy/README.md +1 -1
  21. package/cliproxy/compose.yaml +4 -0
  22. package/cliproxy/scripts/checkpoint.py +1 -1
  23. package/docs/authentication.md +90 -0
  24. package/docs/declared.md +2 -0
  25. package/docs/machine-setup.md +22 -12
  26. package/docs/operations.md +87 -31
  27. package/docs/runbooks.md +380 -0
  28. package/docs/state.md +13 -7
  29. package/docs/ubuntu-sync.md +7 -6
  30. package/examples/fleet.json +19 -1
  31. package/llms.txt +2 -0
  32. package/mise.toml +7 -0
  33. package/package.json +2 -2
  34. package/schema/check-ids.json +522 -0
  35. package/schema/fleet.schema.json +178 -1
  36. package/schema/output/check.schema.json +23 -0
  37. package/schema/output/error.schema.json +14 -0
  38. package/schema/output/fleet-report.schema.json +79 -0
  39. package/schema/output/fleet-sync.schema.json +112 -0
  40. package/schema/output/report.schema.json +38 -0
  41. package/src/auth-checks.ts +3 -0
  42. package/src/cli/agent-guide.ts +5 -3
  43. package/src/cli/fleet-report.ts +126 -0
  44. package/src/cli/fleet-sync-manager.ts +11 -0
  45. package/src/cli/fleet-sync-plan.ts +200 -0
  46. package/src/cli/fleet-sync-remote.ts +3 -1
  47. package/src/cli/fleet-sync-schedule.ts +142 -35
  48. package/src/cli/fleet-sync.ts +90 -8
  49. package/src/cli/fleet-watch.ts +198 -0
  50. package/src/cli/fleet.ts +4 -0
  51. package/src/cli/register-fleet.ts +51 -0
  52. package/src/cli/registry.ts +13 -0
  53. package/src/cli/schedule-logs.ts +36 -0
  54. package/src/clock.ts +63 -0
  55. package/src/definition.ts +142 -5
  56. package/src/git-identity.ts +160 -0
  57. package/src/machine-health.ts +51 -4
  58. package/src/manager-health.ts +205 -0
  59. package/src/manager-sync.ts +12 -2
  60. package/src/monitoring/hub-backup.ts +63 -17
  61. package/src/monitoring/manager-hub.ts +44 -2
  62. package/src/node-converge-linux.ts +16 -5
  63. package/src/notify.ts +239 -0
  64. package/src/remote-bootstrap.ts +87 -25
  65. package/src/remote-operation.ts +213 -0
  66. package/src/schedule-check.ts +40 -0
  67. package/src/secrets.ts +26 -8
  68. package/src/ssh.ts +10 -2
  69. package/src/state/history.ts +172 -0
  70. package/src/state/store.ts +11 -4
  71. package/src/tailscale.ts +51 -0
  72. package/src/toolchain-checks.ts +8 -3
  73. package/src/worker-capability-checks.ts +167 -0
  74. package/src/worker-capacity.ts +19 -7
  75. package/src/worker-host-checks.ts +32 -0
  76. package/src/worker-policy.ts +33 -0
  77. 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. On the manager it checks the release out into
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.
@@ -316,9 +320,11 @@ fleet.json contents.
316
320
  `fleet-sync` fetches that copy again when it is deleted, but the proxy then
317
321
  needs `devfleet cliproxy install` once more.
318
322
  - The manager is a Mac host. There is no container image of the manager.
319
- - `fleet-sync --schedule enable` (unattended runs on a timer) is not available
320
- from the published package yet, so nothing re-applies the fleet until you
321
- run `fleet-sync`.
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.
322
328
  - Not everything on a machine is pinned. [What is declared, and
323
329
  where](docs/declared.md) lists what is and what is not: most of a worker's
324
330
  own packages, the manager's applications and every login are outside it.
@@ -327,18 +333,18 @@ fleet.json contents.
327
333
 
328
334
  ## Repository layout
329
335
 
330
- | Path | What |
331
- | ------------ | ----------------------------------------------------------------- |
332
- | `src/` | Inventory, orchestration, verification, the report; `src/cli` CLI |
333
- | `src/state/` | The fleet state database and its replication to the bucket |
334
- | `ansible/` | Machine policy: `ubuntu/`, `macos/`, `monitoring/` |
335
- | `agents/` | Agent launchers T3 runs, and their registry |
336
- | `cliproxy/` | The CLIProxyAPI service: install, audit, backup, model health |
337
- | `bootstrap/` | Toolchain bootstrap and the manager and worker entry scripts |
338
- | `schema/` | JSON schemas for `fleet.json` and the private roster |
339
- | `examples/` | A complete example configuration |
340
- | `features/` | What dev-fleet does, as Gherkin features: the tests |
341
- | `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 |
342
348
 
343
349
  ## Develop
344
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
  }
@@ -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, idle checkouts
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 = min(before.values()) < args.pressure_mib * MIB
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,
@@ -3,14 +3,15 @@
3
3
 
4
4
  Managed by personal fleet-sync (tasks/resources.yml) as
5
5
  personal-fleet-disk-guard.service. Every interval it measures free space on the
6
- watched filesystems and the bytes each leaf cgroup under user.slice wrote. When
7
- any watched filesystem is below the floor, it kills only the single cgroup that
6
+ watched filesystems (bytes and inodes) and the bytes each leaf cgroup under user.slice wrote. When
7
+ any watched filesystem is below the floor of free bytes or of free inodes, it
8
+ kills only the single cgroup that
8
9
  wrote the most during the last interval, and only if that is at least the write
9
10
  threshold. It never deletes files, never touches system services and never
10
11
  kills idle work. Free space must be recovered by a person.
11
12
 
12
- Usage: disk-guard.py --path / --path /home/me [--floor-mib N] [--write-mib N]
13
- [--interval S] [--cgroup-root DIR] [--dry-run]
13
+ Usage: disk-guard.py --path / --path /home/me [--floor-mib N]
14
+ [--inode-floor-percent N] [--write-mib N] [--interval S] [--cgroup-root DIR] [--dry-run]
14
15
  """
15
16
  import argparse
16
17
  import json
@@ -61,6 +62,21 @@ 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 low_paths(free, inodes, floor, inode_floor):
74
+ """The watched paths below either floor: bytes, or free inodes in percent."""
75
+ return [path for path in free
76
+ if free[path] < floor
77
+ or (inodes.get(path) is not None and inodes[path] < inode_floor)]
78
+
79
+
64
80
  def log(**event):
65
81
  print(json.dumps(event, sort_keys=True), flush=True)
66
82
 
@@ -69,6 +85,7 @@ def main(argv):
69
85
  parser = argparse.ArgumentParser()
70
86
  parser.add_argument('--path', action='append', required=True)
71
87
  parser.add_argument('--floor-mib', type=int, default=3072)
88
+ parser.add_argument('--inode-floor-percent', type=float, default=2)
72
89
  parser.add_argument('--write-mib', type=int, default=32)
73
90
  parser.add_argument('--interval', type=float, default=5.0)
74
91
  parser.add_argument('--cgroup-root', default='/sys/fs/cgroup/user.slice')
@@ -76,6 +93,7 @@ def main(argv):
76
93
  args = parser.parse_args(argv)
77
94
  floor, threshold = args.floor_mib * MIB, args.write_mib * MIB
78
95
  log(event='started', paths=args.path, floor_mib=args.floor_mib,
96
+ inode_floor_percent=args.inode_floor_percent,
79
97
  write_mib=args.write_mib, interval=args.interval, dry_run=args.dry_run)
80
98
  previous = leaf_writes(args.cgroup_root)
81
99
  while True:
@@ -83,7 +101,8 @@ def main(argv):
83
101
  try:
84
102
  current = leaf_writes(args.cgroup_root)
85
103
  free = {path: free_bytes(path) for path in args.path}
86
- low = [path for path, value in free.items() if value < floor]
104
+ inodes = {path: free_inodes_percent(path) for path in args.path}
105
+ low = low_paths(free, inodes, floor, args.inode_floor_percent)
87
106
  victim = choose_victim(previous, current, bool(low), threshold)
88
107
  previous = current
89
108
  if victim is None:
@@ -48,6 +48,55 @@
48
48
  "diskGcPressureDockerAgeHours": 24,
49
49
  "diskGcPressureCaches": [".bun/install/cache"],
50
50
  "dockerLogMaxSize": "50m",
51
- "dockerLogMaxFiles": "3"
51
+ "dockerLogMaxFiles": "3",
52
+ "inodeWarnFreePercent": 10,
53
+ "inodeFailFreePercent": 2
54
+ },
55
+ "clock": {
56
+ "warnOffsetSeconds": 2,
57
+ "failOffsetSeconds": 30
58
+ },
59
+ "rebootPendingFailDays": 14,
60
+ "dockerSmokeTest": {
61
+ "image": "busybox:1.37.0@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec",
62
+ "timeoutSeconds": 60
63
+ },
64
+ "browser": {
65
+ "packages": {
66
+ "fonts-liberation": "1:2.1.5-3build1",
67
+ "libasound2t64": "1.2.15.3-1ubuntu1.1",
68
+ "libatk-bridge2.0-0t64": "2.60.4-0ubuntu0.1",
69
+ "libatk1.0-0t64": "2.60.4-0ubuntu0.1",
70
+ "libatspi2.0-0t64": "2.60.4-0ubuntu0.1",
71
+ "libcairo2": "1.18.4-3",
72
+ "libcups2t64": "2.4.16-1ubuntu1.3",
73
+ "libdbus-1-3": "1.16.2-2ubuntu4",
74
+ "libdrm2": "2.4.131-1",
75
+ "libgbm1": "26.0.8-1ubuntu0.3",
76
+ "libglib2.0-0t64": "2.88.0-1ubuntu0.1",
77
+ "libnspr4": "2:4.38.2-1ubuntu1",
78
+ "libnss3": "2:3.120-1ubuntu2.2",
79
+ "libpango-1.0-0": "1.57.0-1",
80
+ "libx11-6": "2:1.8.13-1ubuntu0.1",
81
+ "libxcb1": "1.17.0-2ubuntu1",
82
+ "libxcomposite1": "1:0.4.6-1build1",
83
+ "libxdamage1": "1:1.1.7-1",
84
+ "libxext6": "2:1.3.4-1build3",
85
+ "libxfixes3": "1:6.0.0-2build2",
86
+ "libxkbcommon0": "1.13.1-1",
87
+ "libxrandr2": "2:1.5.4-1build1"
88
+ },
89
+ "headlessShell": {
90
+ "version": "158.0.8127.18",
91
+ "amd64": {
92
+ "archive": "linux64/chrome-headless-shell-linux64",
93
+ "sha256": "18352d22cb0cdf520cbff2ad8c41391f0e02a72886cb53b0fde4bdfc4bd075d6"
94
+ },
95
+ "arm64": {
96
+ "archive": "linux-arm64/chrome-headless-shell-linux-arm64",
97
+ "sha256": "b136462898353d94c7fab50f9cdaa6d8f0d4c190153916936aa0a03353a26bf2"
98
+ }
99
+ },
100
+ "launchTimeoutSeconds": 60
52
101
  }
53
102
  }