@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.
Files changed (129) hide show
  1. package/README.md +50 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/manager.yml +3 -0
  4. package/ansible/macos/policy.json +11 -1
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/compose.yml +10 -0
  7. package/ansible/monitoring/container-watchdog.py +118 -0
  8. package/ansible/monitoring/hub-darwin.yml +146 -0
  9. package/ansible/net/remote-probe.sh +5 -0
  10. package/ansible/ubuntu/baseline.yml +2 -0
  11. package/ansible/ubuntu/disk-gc.py +25 -3
  12. package/ansible/ubuntu/disk-guard.py +24 -5
  13. package/ansible/ubuntu/policy.json +51 -1
  14. package/ansible/ubuntu/t3-desktop.yml +19 -0
  15. package/ansible/ubuntu/t3-health.sh +93 -22
  16. package/ansible/ubuntu/t3-idle.py +42 -0
  17. package/ansible/ubuntu/t3-policy.yml +4 -11
  18. package/ansible/ubuntu/tasks/browser.yml +56 -0
  19. package/ansible/ubuntu/tasks/docker.yml +52 -0
  20. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  21. package/ansible/ubuntu/tasks/resources.yml +2 -2
  22. package/ansible/ubuntu/tasks/services.yml +13 -0
  23. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  24. package/ansible/ubuntu/wip-guard.py +121 -0
  25. package/bootstrap/doctor.ts +32 -6
  26. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  27. package/bootstrap/native-t3.ts +22 -11
  28. package/bootstrap/setup.ts +23 -8
  29. package/bootstrap/worker-tools.ts +118 -0
  30. package/cliproxy/README.md +4 -4
  31. package/cliproxy/compose.yaml +4 -0
  32. package/cliproxy/scripts/accounts.py +216 -0
  33. package/cliproxy/scripts/checkpoint.py +30 -2
  34. package/cliproxy/scripts/cli.py +1 -1
  35. package/cliproxy/scripts/clientkeys.py +99 -0
  36. package/cliproxy/scripts/lease.py +80 -0
  37. package/cliproxy/scripts/ops +137 -25
  38. package/cliproxy/scripts/restore-verify +15 -0
  39. package/docs/accounts.md +154 -0
  40. package/docs/authentication.md +90 -0
  41. package/docs/declared.md +5 -1
  42. package/docs/durability.md +26 -0
  43. package/docs/machine-setup.md +26 -15
  44. package/docs/operations.md +87 -31
  45. package/docs/runbooks.md +380 -0
  46. package/docs/state.md +13 -7
  47. package/docs/topology.md +2 -0
  48. package/docs/ubuntu-sync.md +35 -28
  49. package/examples/fleet.json +19 -1
  50. package/llms.txt +3 -0
  51. package/mise.toml +10 -1
  52. package/package.json +2 -2
  53. package/schema/check-ids.json +522 -0
  54. package/schema/fleet.schema.json +244 -1
  55. package/schema/output/check.schema.json +23 -0
  56. package/schema/output/error.schema.json +14 -0
  57. package/schema/output/fleet-report.schema.json +79 -0
  58. package/schema/output/fleet-sync.schema.json +112 -0
  59. package/schema/output/report.schema.json +38 -0
  60. package/src/accounts/checks.ts +220 -0
  61. package/src/accounts/context.ts +76 -0
  62. package/src/accounts/propagate.ts +62 -0
  63. package/src/accounts/roster.ts +85 -0
  64. package/src/accounts/selection.ts +67 -0
  65. package/src/accounts/sessions.ts +120 -0
  66. package/src/agents/account-relay.ts +176 -26
  67. package/src/agents/launch.ts +84 -5
  68. package/src/agents/native-verify.ts +128 -2
  69. package/src/auth-checks.ts +3 -0
  70. package/src/cli/agent-guide.ts +5 -3
  71. package/src/cli/fleet-accounts.ts +253 -0
  72. package/src/cli/fleet-report.ts +126 -0
  73. package/src/cli/fleet-sync-accounts.ts +58 -0
  74. package/src/cli/fleet-sync-manager.ts +31 -4
  75. package/src/cli/fleet-sync-output.ts +3 -2
  76. package/src/cli/fleet-sync-plan.ts +200 -0
  77. package/src/cli/fleet-sync-remote.ts +23 -14
  78. package/src/cli/fleet-sync-schedule.ts +142 -35
  79. package/src/cli/fleet-sync.ts +126 -8
  80. package/src/cli/fleet-watch.ts +198 -0
  81. package/src/cli/fleet.ts +4 -0
  82. package/src/cli/register-fleet.ts +86 -0
  83. package/src/cli/registry.ts +26 -0
  84. package/src/cli/schedule-logs.ts +36 -0
  85. package/src/clock.ts +63 -0
  86. package/src/definition.ts +226 -7
  87. package/src/git-identity.ts +160 -0
  88. package/src/ide/definition.ts +86 -0
  89. package/src/ide/ide.ts +43 -0
  90. package/src/ide/index.ts +5 -0
  91. package/src/ide/orchestrate.ts +50 -0
  92. package/src/ide/t3.ts +111 -0
  93. package/src/machine-health.ts +51 -4
  94. package/src/manager-health.ts +205 -0
  95. package/src/manager-sync.ts +13 -17
  96. package/src/monitoring/hub-backup.ts +63 -17
  97. package/src/monitoring/manager-hub.ts +44 -2
  98. package/src/node-converge-linux.ts +44 -12
  99. package/src/notify.ts +239 -0
  100. package/src/platform/darwin.ts +0 -13
  101. package/src/platform/fallback.ts +0 -8
  102. package/src/platform/linux.ts +0 -10
  103. package/src/platform/t3-policy.ts +33 -6
  104. package/src/platform/t3-service.ts +104 -0
  105. package/src/platform/types.ts +0 -4
  106. package/src/platform/windows.ts +0 -14
  107. package/src/remote-bootstrap.ts +87 -25
  108. package/src/remote-entry.ts +15 -1
  109. package/src/remote-operation.ts +213 -0
  110. package/src/schedule-check.ts +40 -0
  111. package/src/secrets.ts +26 -8
  112. package/src/service-auth.ts +15 -5
  113. package/src/ssh.ts +10 -2
  114. package/src/state/history.ts +172 -0
  115. package/src/state/store.ts +11 -4
  116. package/src/t3/accounts.ts +7 -1
  117. package/src/t3/models.ts +133 -0
  118. package/src/t3/pairing.ts +31 -0
  119. package/src/t3/profile.ts +203 -0
  120. package/src/t3/version.ts +119 -0
  121. package/src/t3-linked-check.ts +1 -1
  122. package/src/tailscale.ts +51 -0
  123. package/src/toolchain-checks.ts +8 -3
  124. package/src/transport.ts +3 -2
  125. package/src/worker-capability-checks.ts +167 -0
  126. package/src/worker-capacity.ts +19 -7
  127. package/src/worker-host-checks.ts +68 -13
  128. package/src/worker-policy.ts +33 -0
  129. 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.
@@ -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 on a timer) is not available
292
- from the published package yet, so nothing re-applies the fleet until you
293
- 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.
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` and the private roster |
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
@@ -28,3 +28,6 @@
28
28
  observed: "{{ ac_section | regex_findall('^\\s*' ~ item.key ~ '\\s+(\\S+)', multiline=True) | first | default('') }}"
29
29
  when: observed != item.value
30
30
  changed_when: true
31
+
32
+ - name: Stage the fleet's T3 policy
33
+ ansible.builtin.import_playbook: t3-policy.yml
@@ -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, 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,