@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.
- package/README.md +22 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/policy.json +11 -1
- 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 +50 -1
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +1 -1
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/checkpoint.py +1 -1
- package/docs/authentication.md +90 -0
- package/docs/declared.md +2 -0
- package/docs/machine-setup.md +22 -12
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/ubuntu-sync.md +7 -6
- package/examples/fleet.json +19 -1
- package/llms.txt +2 -0
- package/mise.toml +7 -0
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +178 -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/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-manager.ts +11 -0
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +3 -1
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +90 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +51 -0
- package/src/cli/registry.ts +13 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +142 -5
- package/src/git-identity.ts +160 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +12 -2
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +16 -5
- package/src/notify.ts +239 -0
- package/src/remote-bootstrap.ts +87 -25
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +32 -0
- 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.
|
|
@@ -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
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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,
|
|
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,
|
|
@@ -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
|
|
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]
|
|
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
|
-
|
|
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
|
}
|