@crvouga/dev-fleet 0.4.0 → 0.6.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 +3 -1
- package/ansible/macos/policy.json +1 -0
- package/ansible/platforms.json +111 -0
- package/ansible/ubuntu/authorize-sudo.sh +7 -16
- package/ansible/ubuntu/baseline.yml +6 -28
- package/ansible/ubuntu/disk-gc.py +186 -4
- package/ansible/ubuntu/policy.json +18 -3
- package/ansible/ubuntu/t3-desktop.yml +2 -2
- package/ansible/ubuntu/tailscale.yml +20 -9
- package/ansible/ubuntu/tasks/dpkg.yml +46 -0
- package/ansible/ubuntu/tasks/github.yml +50 -0
- package/ansible/ubuntu/tasks/install.yml +38 -0
- package/ansible/ubuntu/tasks/packages.yml +4 -55
- package/ansible/ubuntu/tasks/release.yml +25 -0
- package/ansible/ubuntu/tasks/resources.yml +6 -5
- package/ansible/ubuntu/tasks/snapshot.yml +50 -0
- package/ansible/ubuntu/tasks/unattended-upgrades.yml +24 -0
- package/bootstrap/fleet-remote-bootstrap.sh +18 -41
- package/bootstrap/worker-auth.ts +6 -7
- package/bootstrap/worker-github.ts +55 -44
- package/docs/authentication.md +4 -1
- package/docs/consumer-setup.md +37 -30
- package/docs/declared.md +4 -5
- package/docs/github.md +107 -0
- package/docs/operations.md +1 -1
- package/docs/runbooks.md +17 -6
- package/docs/support-matrix.md +36 -0
- package/docs/ubuntu-sync.md +14 -12
- package/docs/worker-contract.md +28 -1
- package/package.json +2 -2
- package/schema/check-ids.json +55 -7
- package/schema/fleet.schema.json +86 -4
- package/src/auth-checks.ts +14 -6
- package/src/cli/fleet-sync-manager.ts +9 -0
- package/src/cli/fleet-sync-remote.ts +1 -1
- package/src/cli/github-verify.ts +57 -0
- package/src/cli/register-fleet.ts +2 -0
- package/src/cli/registry.ts +7 -0
- package/src/definition.ts +29 -8
- package/src/github/checks.ts +412 -0
- package/src/github/converge.ts +62 -0
- package/src/github/copilot.ts +54 -0
- package/src/github/credential.ts +21 -0
- package/src/github/policy.ts +141 -0
- package/src/github/sync.ts +123 -0
- package/src/github/workflow.ts +223 -0
- package/src/machine/platform.ts +48 -28
- package/src/machine/support-matrix.ts +76 -0
- package/src/manager-sync.ts +15 -7
- package/src/network-definition.ts +1 -1
- package/src/node-converge-linux.ts +3 -2
- package/src/node-converge-shared.ts +6 -2
- package/src/node-converge.ts +1 -1
- package/src/platform/debian-version.ts +62 -0
- package/src/platform/ubuntu-audit.ts +11 -4
- package/src/platform/ubuntu-release.ts +41 -0
- package/src/remote-bootstrap.ts +2 -0
- package/src/repair-catalog.ts +2 -2
- package/src/secrets.ts +58 -13
- package/src/service-auth.ts +2 -2
- package/src/worker-access-checks.ts +66 -16
- package/src/worker-host-checks.ts +36 -10
- package/src/worker-readiness.ts +1 -1
package/README.md
CHANGED
|
@@ -42,7 +42,9 @@ dev-fleet is opinionated. These are part of it, not options:
|
|
|
42
42
|
- The **manager** is a macOS machine. You run `fleet-sync` on it, and it hosts
|
|
43
43
|
the shared services (the CLI proxy and the monitoring hub) in Docker Desktop.
|
|
44
44
|
- **Workers** are Ubuntu 26.04 machines reached over SSH. They are stateless:
|
|
45
|
-
everything on them is rebuilt from Git and your secrets.
|
|
45
|
+
everything on them is rebuilt from Git and your secrets. The
|
|
46
|
+
[support matrix](docs/support-matrix.md) lists every platform and what
|
|
47
|
+
happens to it.
|
|
46
48
|
- The fleet state is kept in an **S3-compatible bucket** you own.
|
|
47
49
|
- Machines reach each other over **Tailscale**.
|
|
48
50
|
- Coding agents (Claude Code, Codex, Grok, OpenCode, Antigravity) run through
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"rows": [
|
|
4
|
+
{
|
|
5
|
+
"role": "manager",
|
|
6
|
+
"platform": "any other",
|
|
7
|
+
"versions": "any",
|
|
8
|
+
"architectures": "any",
|
|
9
|
+
"status": "refused",
|
|
10
|
+
"note": "The fleet configuration accepts only a macOS manager."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"role": "worker",
|
|
14
|
+
"platform": "Ubuntu",
|
|
15
|
+
"versions": "any other release",
|
|
16
|
+
"architectures": "any",
|
|
17
|
+
"status": "refused",
|
|
18
|
+
"note": "UBUNTU_BASELINE_MISMATCH"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"role": "worker",
|
|
22
|
+
"platform": "Ubuntu",
|
|
23
|
+
"versions": "an accepted release",
|
|
24
|
+
"architectures": "any other",
|
|
25
|
+
"status": "refused",
|
|
26
|
+
"note": "UBUNTU_BASELINE_MISMATCH"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"role": "worker",
|
|
30
|
+
"platform": "Debian",
|
|
31
|
+
"versions": "any",
|
|
32
|
+
"architectures": "any",
|
|
33
|
+
"status": "refused",
|
|
34
|
+
"note": "UBUNTU_BASELINE_MISMATCH"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"role": "worker",
|
|
38
|
+
"platform": "macOS",
|
|
39
|
+
"versions": "any",
|
|
40
|
+
"architectures": "any",
|
|
41
|
+
"status": "refused",
|
|
42
|
+
"note": "The fleet configuration accepts only Linux workers."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"role": "worker",
|
|
46
|
+
"platform": "Windows",
|
|
47
|
+
"versions": "any",
|
|
48
|
+
"architectures": "any",
|
|
49
|
+
"status": "refused",
|
|
50
|
+
"note": "The fleet configuration accepts only Linux workers."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"role": "worker",
|
|
54
|
+
"platform": "WSL2",
|
|
55
|
+
"environment": "wsl",
|
|
56
|
+
"versions": "any",
|
|
57
|
+
"architectures": "any",
|
|
58
|
+
"status": "refused",
|
|
59
|
+
"note": "WORKER_ENVIRONMENT_UNSUPPORTED"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"role": "worker",
|
|
63
|
+
"platform": "container",
|
|
64
|
+
"environment": "container",
|
|
65
|
+
"versions": "any",
|
|
66
|
+
"architectures": "any",
|
|
67
|
+
"status": "refused",
|
|
68
|
+
"note": "WORKER_ENVIRONMENT_UNSUPPORTED"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"role": "toolchain only",
|
|
72
|
+
"platform": "macOS",
|
|
73
|
+
"versions": "any",
|
|
74
|
+
"architectures": "arm64, x86_64",
|
|
75
|
+
"status": "best effort",
|
|
76
|
+
"note": "No fleet role: devfleet bootstrap installs the pinned tools (docs/machine-setup.md)."
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"role": "toolchain only",
|
|
80
|
+
"platform": "Linux",
|
|
81
|
+
"versions": "any",
|
|
82
|
+
"architectures": "x86_64, aarch64",
|
|
83
|
+
"status": "best effort",
|
|
84
|
+
"note": "No fleet role: devfleet bootstrap installs the pinned tools (docs/machine-setup.md)."
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"role": "toolchain only",
|
|
88
|
+
"platform": "WSL2",
|
|
89
|
+
"versions": "any",
|
|
90
|
+
"architectures": "x86_64",
|
|
91
|
+
"status": "best effort",
|
|
92
|
+
"note": "Treated as Linux: devfleet bootstrap installs the pinned tools (docs/machine-setup.md)."
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"role": "toolchain only",
|
|
96
|
+
"platform": "Windows",
|
|
97
|
+
"versions": "any",
|
|
98
|
+
"architectures": "x64",
|
|
99
|
+
"status": "best effort",
|
|
100
|
+
"note": "No fleet role: bootstrap/bootstrap.ps1 installs the pinned tools (docs/machine-setup.md)."
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"role": "toolchain only",
|
|
104
|
+
"platform": "Alpine",
|
|
105
|
+
"versions": "any",
|
|
106
|
+
"architectures": "any",
|
|
107
|
+
"status": "refused",
|
|
108
|
+
"note": "musl: the pinned mise build needs glibc."
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
@@ -3,14 +3,11 @@
|
|
|
3
3
|
# SSH terminal so sudo reads the operator's password itself; never capture it.
|
|
4
4
|
set -eu
|
|
5
5
|
umask 077
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
[ "$(sw_vers -productVersion | cut -d. -f1)" -ge 14 ] || exit 1 ;;
|
|
12
|
-
*) exit 1 ;;
|
|
13
|
-
esac
|
|
6
|
+
# Workers are Ubuntu. Which releases is checked by the bootstrap that
|
|
7
|
+
# follows, against ansible/ubuntu/policy.json; this only grants sudo.
|
|
8
|
+
[ "$(uname -s)" = Linux ] || exit 1
|
|
9
|
+
. /etc/os-release
|
|
10
|
+
[ "$ID" = ubuntu ] || exit 1
|
|
14
11
|
user=$(id -un)
|
|
15
12
|
case "$user" in root|''|*[!a-z0-9_-]*) exit 1;; esac
|
|
16
13
|
command -v sudo >/dev/null 2>&1 || exit 1
|
|
@@ -23,11 +20,7 @@ mkdir -p "$root"
|
|
|
23
20
|
chmod 700 "$root"
|
|
24
21
|
[ ! -L "$root/remote-operation.lock" ] || exit 1
|
|
25
22
|
exec 9>"$root/remote-operation.lock"
|
|
26
|
-
|
|
27
|
-
flock -n 9 || exit 1
|
|
28
|
-
else
|
|
29
|
-
python3 -c 'import fcntl; fcntl.flock(9, fcntl.LOCK_EX | fcntl.LOCK_NB)' 9>&9 || exit 1
|
|
30
|
-
fi
|
|
23
|
+
flock -n 9 || exit 1
|
|
31
24
|
sudo -v
|
|
32
25
|
file=$(mktemp)
|
|
33
26
|
trap 'rm -f "$file"' EXIT
|
|
@@ -45,7 +38,5 @@ if sudo -n test -e "$target"; then
|
|
|
45
38
|
}
|
|
46
39
|
sudo -n cp -p -- "$target" "$target.before-fleet-enrollment"
|
|
47
40
|
fi
|
|
48
|
-
|
|
49
|
-
[ "$(uname -s)" != Darwin ] || group=wheel
|
|
50
|
-
sudo -n install -o root -g "$group" -m 0440 -- "$file" "$target"
|
|
41
|
+
sudo -n install -o root -g root -m 0440 -- "$file" "$target"
|
|
51
42
|
sudo -n true
|
|
@@ -7,15 +7,13 @@
|
|
|
7
7
|
vars_files:
|
|
8
8
|
- policy.json
|
|
9
9
|
pre_tasks:
|
|
10
|
-
-
|
|
10
|
+
- ansible.builtin.import_tasks: tasks/release.yml
|
|
11
|
+
- name: Require an explicit non-root user
|
|
11
12
|
ansible.builtin.assert:
|
|
12
13
|
that:
|
|
13
|
-
- ansible_facts.distribution == 'Ubuntu'
|
|
14
|
-
- ansible_facts.distribution_version == release
|
|
15
|
-
- ansible_facts.architecture in ['x86_64', 'aarch64']
|
|
16
14
|
- development_user is match('^[a-z_][a-z0-9_-]{0,31}$')
|
|
17
15
|
- development_user != 'root'
|
|
18
|
-
fail_msg: Unsupported
|
|
16
|
+
fail_msg: Unsupported user; name the non-root developer account.
|
|
19
17
|
- name: Read the designated existing account
|
|
20
18
|
ansible.builtin.getent:
|
|
21
19
|
database: passwd
|
|
@@ -25,6 +23,7 @@
|
|
|
25
23
|
that:
|
|
26
24
|
- ansible_facts.getent_passwd[development_user][1] | int == development_uid | int
|
|
27
25
|
- ansible_facts.getent_passwd[development_user][4] == development_home
|
|
26
|
+
- ansible.builtin.import_tasks: tasks/dpkg.yml
|
|
28
27
|
# Memory and disk safety must land even when the disk is nearly full.
|
|
29
28
|
- ansible.builtin.import_tasks: tasks/resources.yml
|
|
30
29
|
- name: Inspect capacity before package and service changes
|
|
@@ -55,29 +54,6 @@
|
|
|
55
54
|
fail_msg: DOCKER_OWNER_CONFLICT; migrate the existing Docker owner explicitly before applying the Ubuntu-native baseline.
|
|
56
55
|
- name: Inspect existing service ownership
|
|
57
56
|
ansible.builtin.service_facts:
|
|
58
|
-
- name: Wait for legitimate package transactions before inspecting dpkg
|
|
59
|
-
ansible.builtin.command:
|
|
60
|
-
argv:
|
|
61
|
-
- /usr/bin/python3
|
|
62
|
-
- '-c'
|
|
63
|
-
- |
|
|
64
|
-
import fcntl
|
|
65
|
-
files = [open(path, 'a') for path in ['/var/lib/dpkg/lock-frontend', '/var/lib/dpkg/lock']]
|
|
66
|
-
for handle in files:
|
|
67
|
-
fcntl.lockf(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
68
|
-
register: apt_idle
|
|
69
|
-
changed_when: false
|
|
70
|
-
retries: 24
|
|
71
|
-
delay: 5
|
|
72
|
-
until: apt_idle.rc == 0
|
|
73
|
-
- name: Inspect package database consistency
|
|
74
|
-
ansible.builtin.command: dpkg --audit
|
|
75
|
-
changed_when: false
|
|
76
|
-
register: dpkg_audit
|
|
77
|
-
- name: Require package database recovery before modifying packages
|
|
78
|
-
ansible.builtin.assert:
|
|
79
|
-
that: dpkg_audit.stdout | trim == ''
|
|
80
|
-
fail_msg: APT_STATE_BROKEN; inspect dpkg --audit and use reviewed package recovery. Never remove package lock files.
|
|
81
57
|
tasks:
|
|
82
58
|
- ansible.builtin.import_tasks: tasks/packages.yml
|
|
83
59
|
- ansible.builtin.import_tasks: tasks/docker.yml
|
|
@@ -89,3 +65,5 @@
|
|
|
89
65
|
- ansible.builtin.import_tasks: tasks/ssh.yml
|
|
90
66
|
- ansible.builtin.import_tasks: tasks/credentials.yml
|
|
91
67
|
when: development_source is defined
|
|
68
|
+
- ansible.builtin.import_tasks: tasks/github.yml
|
|
69
|
+
when: development_source is defined
|
|
@@ -11,19 +11,31 @@ What it deletes:
|
|
|
11
11
|
process is running inside it;
|
|
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
|
-
* the apt package cache
|
|
14
|
+
* the apt package cache;
|
|
15
|
+
* agent worktrees under each --worktree-root that nobody has used for
|
|
16
|
+
--worktree-idle-days, only when Git says they hold no uncommitted,
|
|
17
|
+
untracked or unpushed work and no process runs in them (the branch is
|
|
18
|
+
kept);
|
|
19
|
+
* the oldest files of each --cache-cap DIR=MIB cache beyond its size;
|
|
20
|
+
* all but the newest N files of each --keep-newest DIR=N directory (the
|
|
21
|
+
downloaded T3 packages: the current one and the previous one);
|
|
22
|
+
* tool versions no mise configuration uses (--mise, run as --user).
|
|
15
23
|
When a watched filesystem has less than --pressure-mib free, or less than
|
|
16
24
|
--pressure-inode-percent of its inodes free, idle checkouts
|
|
17
25
|
qualify after --pressure-idle-hours, Docker leftovers after
|
|
18
26
|
--pressure-docker-age-hours, and aged entries of each --pressure-cache
|
|
19
27
|
directory are removed too.
|
|
20
28
|
|
|
21
|
-
It never deletes a checkout, a tracked file, uncommitted
|
|
22
|
-
a volume. Every decision is one JSON line on stdout
|
|
29
|
+
It never deletes a main checkout, a tracked file, uncommitted or unpushed
|
|
30
|
+
work, a container or a volume. Every decision is one JSON line on stdout, and
|
|
31
|
+
the last one says how many MiB were reclaimed.
|
|
23
32
|
|
|
24
33
|
Usage: disk-gc.py --home H [--path P ...] [--idle-days N] [--pressure-mib N]
|
|
25
34
|
[--pressure-inode-percent N] [--pressure-idle-hours N] [--docker-age-hours N]
|
|
26
35
|
[--pressure-docker-age-hours N] [--pressure-cache DIR ...]
|
|
36
|
+
[--worktree-root DIR ...] [--worktree-idle-days N]
|
|
37
|
+
[--cache-cap DIR=MIB ...] [--keep-newest DIR=N ...]
|
|
38
|
+
[--mise PATH] [--user NAME]
|
|
27
39
|
[--max-depth N] [--proc DIR] [--home-only] [--dry-run]
|
|
28
40
|
|
|
29
41
|
--home-only skips the machine-wide Docker and apt steps.
|
|
@@ -297,6 +309,150 @@ def collect_cache(directory, age_seconds, now, dry_run):
|
|
|
297
309
|
return freed
|
|
298
310
|
|
|
299
311
|
|
|
312
|
+
def linked_worktrees(root, max_depth=3):
|
|
313
|
+
"""Linked worktrees (a .git file, never a repository) under root."""
|
|
314
|
+
found = []
|
|
315
|
+
|
|
316
|
+
def walk(directory, depth):
|
|
317
|
+
dot_git = os.path.join(directory, '.git')
|
|
318
|
+
if os.path.lexists(dot_git):
|
|
319
|
+
if not is_real_dir(dot_git) and not os.path.islink(dot_git):
|
|
320
|
+
found.append(directory)
|
|
321
|
+
return
|
|
322
|
+
if depth >= max_depth:
|
|
323
|
+
return
|
|
324
|
+
try:
|
|
325
|
+
entries = list(os.scandir(directory))
|
|
326
|
+
except OSError:
|
|
327
|
+
return
|
|
328
|
+
for entry in entries:
|
|
329
|
+
if entry.is_dir(follow_symlinks=False) and entry.name not in REGENERABLE:
|
|
330
|
+
walk(entry.path, depth + 1)
|
|
331
|
+
|
|
332
|
+
walk(root, 0)
|
|
333
|
+
return sorted(found)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def unsaved_work(worktree):
|
|
337
|
+
"""Why Git cannot vouch for a worktree, or None when everything in it is
|
|
338
|
+
committed and every commit is on a remote."""
|
|
339
|
+
status = run_git(worktree, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
|
|
340
|
+
if status is None or status.returncode != 0:
|
|
341
|
+
return 'git_status_failed'
|
|
342
|
+
if status.stdout.strip(b'\0'):
|
|
343
|
+
return 'uncommitted'
|
|
344
|
+
unpushed = run_git(worktree, ['rev-list', '-n', '1', 'HEAD', '--not', '--remotes'])
|
|
345
|
+
if unpushed is None or unpushed.returncode != 0:
|
|
346
|
+
return 'git_rev_list_failed'
|
|
347
|
+
if unpushed.stdout.strip():
|
|
348
|
+
return 'unpushed'
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def collect_worktrees(root, idle_seconds, proc, now, dry_run):
|
|
353
|
+
roots = busy_roots(proc)
|
|
354
|
+
freed = 0
|
|
355
|
+
for worktree in linked_worktrees(root):
|
|
356
|
+
gdir = git_dir(worktree)
|
|
357
|
+
if gdir is None or in_use(worktree, roots):
|
|
358
|
+
continue
|
|
359
|
+
if now - git_activity(worktree, gdir) < idle_seconds:
|
|
360
|
+
continue
|
|
361
|
+
latest = last_activity(worktree, gdir, [])
|
|
362
|
+
if latest is None or now - latest < idle_seconds:
|
|
363
|
+
continue
|
|
364
|
+
reason = unsaved_work(worktree)
|
|
365
|
+
if reason:
|
|
366
|
+
log(event='worktree_kept', worktree=worktree, reason=reason)
|
|
367
|
+
continue
|
|
368
|
+
size = size_of(worktree)
|
|
369
|
+
if not dry_run:
|
|
370
|
+
common = run_git(worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
371
|
+
if common is None or common.returncode != 0:
|
|
372
|
+
continue
|
|
373
|
+
out = subprocess.run(
|
|
374
|
+
GIT + ['--git-dir', common.stdout.decode().strip(), 'worktree', 'remove', '--force', worktree],
|
|
375
|
+
capture_output=True, timeout=600)
|
|
376
|
+
if out.returncode != 0 or os.path.lexists(worktree):
|
|
377
|
+
log(event='worktree_kept', worktree=worktree, reason='git_worktree_remove_failed')
|
|
378
|
+
continue
|
|
379
|
+
freed += size
|
|
380
|
+
log(event='worktree_removed', worktree=worktree, mib=size // MIB, dry_run=dry_run)
|
|
381
|
+
return freed
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def files_under(directory):
|
|
385
|
+
"""Every regular file under directory, never through a link."""
|
|
386
|
+
found = []
|
|
387
|
+
for current, dirs, files in os.walk(directory):
|
|
388
|
+
dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(current, d))]
|
|
389
|
+
for name in files:
|
|
390
|
+
path = os.path.join(current, name)
|
|
391
|
+
try:
|
|
392
|
+
st = os.lstat(path)
|
|
393
|
+
except OSError:
|
|
394
|
+
continue
|
|
395
|
+
if stat.S_ISREG(st.st_mode):
|
|
396
|
+
found.append((st.st_mtime, st.st_blocks * 512, path))
|
|
397
|
+
return found
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def unlink(path, dry_run):
|
|
401
|
+
if dry_run:
|
|
402
|
+
return True
|
|
403
|
+
try:
|
|
404
|
+
os.unlink(path)
|
|
405
|
+
return True
|
|
406
|
+
except OSError:
|
|
407
|
+
return False
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def cap_cache(directory, cap_bytes, dry_run):
|
|
411
|
+
"""Removes the oldest files of a cache until it fits its cap."""
|
|
412
|
+
entries = sorted(files_under(directory))
|
|
413
|
+
total = sum(size for _, size, _ in entries)
|
|
414
|
+
freed = 0
|
|
415
|
+
for _, size, path in entries:
|
|
416
|
+
if total - freed <= cap_bytes:
|
|
417
|
+
break
|
|
418
|
+
if unlink(path, dry_run):
|
|
419
|
+
freed += size
|
|
420
|
+
if not dry_run:
|
|
421
|
+
for current, dirs, files in os.walk(directory, topdown=False):
|
|
422
|
+
if current != directory and not dirs and not files:
|
|
423
|
+
try:
|
|
424
|
+
os.rmdir(current)
|
|
425
|
+
except OSError:
|
|
426
|
+
pass
|
|
427
|
+
if freed:
|
|
428
|
+
log(event='cache_capped', cache=directory, mib=freed // MIB,
|
|
429
|
+
cap_mib=cap_bytes // MIB, dry_run=dry_run)
|
|
430
|
+
return freed
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def keep_newest(directory, count, dry_run):
|
|
434
|
+
"""Removes all but the newest count files directly in directory."""
|
|
435
|
+
try:
|
|
436
|
+
entries = [e for e in os.scandir(directory) if e.is_file(follow_symlinks=False)]
|
|
437
|
+
except OSError:
|
|
438
|
+
return 0
|
|
439
|
+
entries.sort(key=lambda e: e.stat(follow_symlinks=False).st_mtime, reverse=True)
|
|
440
|
+
freed = 0
|
|
441
|
+
for entry in entries[count:]:
|
|
442
|
+
size = entry.stat(follow_symlinks=False).st_blocks * 512
|
|
443
|
+
if unlink(entry.path, dry_run):
|
|
444
|
+
freed += size
|
|
445
|
+
log(event='superseded_removed', path=entry.path, mib=size // MIB, dry_run=dry_run)
|
|
446
|
+
return freed
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def pair(text, kind):
|
|
450
|
+
name, _, value = text.rpartition('=')
|
|
451
|
+
if not name:
|
|
452
|
+
raise argparse.ArgumentTypeError(f'expected DIR={kind}')
|
|
453
|
+
return name, int(value)
|
|
454
|
+
|
|
455
|
+
|
|
300
456
|
def command(argv, dry_run, name):
|
|
301
457
|
if shutil.which(argv[0]) is None:
|
|
302
458
|
return
|
|
@@ -321,6 +477,14 @@ def main(argv):
|
|
|
321
477
|
parser.add_argument('--docker-age-hours', type=int, default=168)
|
|
322
478
|
parser.add_argument('--pressure-docker-age-hours', type=int, default=24)
|
|
323
479
|
parser.add_argument('--pressure-cache', action='append', default=[])
|
|
480
|
+
parser.add_argument('--worktree-root', action='append', default=[])
|
|
481
|
+
parser.add_argument('--worktree-idle-days', type=float, default=30)
|
|
482
|
+
parser.add_argument('--cache-cap', action='append', default=[],
|
|
483
|
+
type=lambda text: pair(text, 'MIB'))
|
|
484
|
+
parser.add_argument('--keep-newest', action='append', default=[],
|
|
485
|
+
type=lambda text: pair(text, 'N'))
|
|
486
|
+
parser.add_argument('--mise')
|
|
487
|
+
parser.add_argument('--user')
|
|
324
488
|
parser.add_argument('--max-depth', type=int, default=4)
|
|
325
489
|
parser.add_argument('--proc', default='/proc')
|
|
326
490
|
parser.add_argument('--home-only', action='store_true')
|
|
@@ -342,6 +506,23 @@ def main(argv):
|
|
|
342
506
|
if pressure:
|
|
343
507
|
for cache in args.pressure_cache:
|
|
344
508
|
freed += collect_cache(os.path.join(home, cache), idle, now, args.dry_run)
|
|
509
|
+
reclaimed = freed
|
|
510
|
+
for root in args.worktree_root:
|
|
511
|
+
reclaimed += collect_worktrees(os.path.join(home, root),
|
|
512
|
+
args.worktree_idle_days * 24 * HOUR,
|
|
513
|
+
args.proc, now, args.dry_run)
|
|
514
|
+
for cache, cap_mib in args.cache_cap:
|
|
515
|
+
reclaimed += cap_cache(os.path.join(home, cache), cap_mib * MIB, args.dry_run)
|
|
516
|
+
for directory, count in args.keep_newest:
|
|
517
|
+
reclaimed += keep_newest(os.path.join(home, directory), count, args.dry_run)
|
|
518
|
+
if args.mise:
|
|
519
|
+
installs = os.path.join(home, '.local', 'share', 'mise', 'installs')
|
|
520
|
+
before_mise = size_of(installs)
|
|
521
|
+
prune = [args.mise, 'prune', '--yes']
|
|
522
|
+
if args.user:
|
|
523
|
+
prune = ['runuser', '-u', args.user, '--', 'env', f'HOME={home}'] + prune
|
|
524
|
+
command(prune, args.dry_run, 'mise_prune')
|
|
525
|
+
reclaimed += max(0, before_mise - size_of(installs))
|
|
345
526
|
if not args.home_only:
|
|
346
527
|
command(['docker', 'image', 'prune', '--all', '--force', '--filter',
|
|
347
528
|
f'until={docker_age}h'], args.dry_run, 'docker_images')
|
|
@@ -350,7 +531,8 @@ def main(argv):
|
|
|
350
531
|
command(['apt-get', 'clean'], args.dry_run, 'apt_cache')
|
|
351
532
|
|
|
352
533
|
after = {p: free_bytes(p) for p in paths}
|
|
353
|
-
log(event='finished', checkout_mib=freed // MIB,
|
|
534
|
+
log(event='finished', checkout_mib=freed // MIB,
|
|
535
|
+
reclaimed_mib=reclaimed // MIB, dry_run=args.dry_run,
|
|
354
536
|
free_mib={p: v // MIB for p, v in after.items()})
|
|
355
537
|
|
|
356
538
|
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"
|
|
4
|
-
|
|
3
|
+
"releases": [
|
|
4
|
+
{
|
|
5
|
+
"release": "26.04",
|
|
6
|
+
"codename": "resolute",
|
|
7
|
+
"architectures": ["amd64", "arm64"]
|
|
8
|
+
}
|
|
9
|
+
],
|
|
5
10
|
"snapshot": "20261101T000000Z",
|
|
6
11
|
"packages": {
|
|
7
12
|
"sudo": "1.9.17p2-1ubuntu3.2",
|
|
@@ -17,7 +22,8 @@
|
|
|
17
22
|
"python3": "3.14.3-0ubuntu2",
|
|
18
23
|
"unzip": "6.0-29ubuntu1",
|
|
19
24
|
"xz-utils": "5.8.3-1ubuntu0.1",
|
|
20
|
-
"ansible-core": "2.20.1-1"
|
|
25
|
+
"ansible-core": "2.20.1-1",
|
|
26
|
+
"systemd-oomd": "259.9-0ubuntu0.1"
|
|
21
27
|
},
|
|
22
28
|
"tailscale": {
|
|
23
29
|
"version": "1.104.5",
|
|
@@ -47,6 +53,15 @@
|
|
|
47
53
|
"diskGcDockerAgeHours": 168,
|
|
48
54
|
"diskGcPressureDockerAgeHours": 24,
|
|
49
55
|
"diskGcPressureCaches": [".bun/install/cache"],
|
|
56
|
+
"diskGcWorktreeRoots": [".t3/worktrees"],
|
|
57
|
+
"diskGcWorktreeIdleDays": 30,
|
|
58
|
+
"diskGcCacheCapsMiB": {
|
|
59
|
+
".bun/install/cache": 4096,
|
|
60
|
+
".npm/_cacache": 2048,
|
|
61
|
+
".cache/uv": 4096,
|
|
62
|
+
".cache/pip": 1024
|
|
63
|
+
},
|
|
64
|
+
"diskGcPackagesKept": 2,
|
|
50
65
|
"dockerLogMaxSize": "50m",
|
|
51
66
|
"dockerLogMaxFiles": "3",
|
|
52
67
|
"inodeWarnFreePercent": 10,
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
# unit starts the app with the graphical session, so the UI is on screen and the
|
|
5
5
|
# server is up after every boot. Starting or migrating is left to fleet-sync.
|
|
6
6
|
#
|
|
7
|
-
# The upstream .deb declares libxss1, which Ubuntu
|
|
8
|
-
# binary does not link. Instead of patching the package or leaving apt with an
|
|
7
|
+
# The upstream .deb declares libxss1, which current Ubuntu releases no longer
|
|
8
|
+
# ship and the binary does not link. Instead of patching the package or leaving apt with an
|
|
9
9
|
# unmet dependency, the verified package payload is unpacked to its own path and
|
|
10
10
|
# its real runtime libraries and bundled AppArmor profile are installed.
|
|
11
11
|
- name: Install the pinned T3 Code desktop app and start it with the session
|
|
@@ -8,17 +8,28 @@
|
|
|
8
8
|
vars_files:
|
|
9
9
|
- policy.json
|
|
10
10
|
tasks:
|
|
11
|
-
-
|
|
12
|
-
ansible.builtin.assert:
|
|
13
|
-
that:
|
|
14
|
-
- ansible_facts.distribution == 'Ubuntu'
|
|
15
|
-
- ansible_facts.distribution_version == release
|
|
11
|
+
- ansible.builtin.import_tasks: tasks/release.yml
|
|
16
12
|
- name: Inspect installed packages before possible network interruption
|
|
17
13
|
ansible.builtin.package_facts:
|
|
18
14
|
manager: apt
|
|
19
|
-
|
|
15
|
+
# The pin is a minimum: a newer Tailscale is kept and reported as a
|
|
16
|
+
# warning by the worker checks; an older one needs a maintenance window.
|
|
17
|
+
- name: Compare the installed Tailscale with its pin
|
|
18
|
+
ansible.builtin.command:
|
|
19
|
+
argv:
|
|
20
|
+
- dpkg
|
|
21
|
+
- --compare-versions
|
|
22
|
+
- '{{ ansible_facts.packages.tailscale[0].version }}'
|
|
23
|
+
- ge
|
|
24
|
+
- '{{ tailscale.version }}'
|
|
25
|
+
register: tailscale_pin
|
|
26
|
+
changed_when: false
|
|
27
|
+
failed_when: tailscale_pin.rc not in [0, 1]
|
|
28
|
+
check_mode: false
|
|
29
|
+
when: "'tailscale' in ansible_facts.packages"
|
|
30
|
+
- name: Require maintenance for an upgrade to the pin
|
|
20
31
|
ansible.builtin.assert:
|
|
21
|
-
that: "'tailscale' not in ansible_facts.packages or
|
|
32
|
+
that: "'tailscale' not in ansible_facts.packages or tailscale_pin.rc == 0"
|
|
22
33
|
fail_msg: TAILSCALE_UPGRADE_PENDING; upgrading an active VPN can disconnect SSH. Review an explicit maintenance operation.
|
|
23
34
|
- name: Inspect existing package-source ownership before writing
|
|
24
35
|
ansible.builtin.find:
|
|
@@ -35,7 +46,7 @@
|
|
|
35
46
|
fail_msg: TAILSCALE_SOURCE_OWNER_CONFLICT; review the existing signed source and its migration before creating another repository entry.
|
|
36
47
|
- name: Install pinned upstream signing key
|
|
37
48
|
ansible.builtin.get_url:
|
|
38
|
-
url: https://pkgs.tailscale.com/stable/ubuntu/
|
|
49
|
+
url: https://pkgs.tailscale.com/stable/ubuntu/{{ ubuntu_release.codename }}.noarmor.gpg
|
|
39
50
|
dest: /usr/share/keyrings/tailscale-archive-keyring.gpg
|
|
40
51
|
checksum: 'sha256:{{ tailscale.keySha256 }}'
|
|
41
52
|
owner: root
|
|
@@ -44,7 +55,7 @@
|
|
|
44
55
|
timeout: 30
|
|
45
56
|
- name: Declare the signed upstream package source
|
|
46
57
|
ansible.builtin.apt_repository:
|
|
47
|
-
repo: deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu
|
|
58
|
+
repo: deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu {{ ubuntu_release.codename }} main
|
|
48
59
|
filename: tailscale
|
|
49
60
|
state: present
|
|
50
61
|
update_cache: false
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
# A package database left inconsistent by an interrupted run is repaired
|
|
3
|
+
# before any package task runs, or stops the play with APT_STATE_BROKEN.
|
|
4
|
+
- name: Wait for legitimate package transactions before inspecting dpkg
|
|
5
|
+
ansible.builtin.command:
|
|
6
|
+
argv:
|
|
7
|
+
- /usr/bin/python3
|
|
8
|
+
- '-c'
|
|
9
|
+
- |
|
|
10
|
+
import fcntl
|
|
11
|
+
files = [open(path, 'a') for path in ['/var/lib/dpkg/lock-frontend', '/var/lib/dpkg/lock']]
|
|
12
|
+
for handle in files:
|
|
13
|
+
fcntl.lockf(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
14
|
+
register: apt_idle
|
|
15
|
+
changed_when: false
|
|
16
|
+
retries: 24
|
|
17
|
+
delay: 5
|
|
18
|
+
until: apt_idle.rc == 0
|
|
19
|
+
- name: Inspect package database consistency
|
|
20
|
+
ansible.builtin.command: dpkg --audit
|
|
21
|
+
changed_when: false
|
|
22
|
+
register: dpkg_audit
|
|
23
|
+
# Power lost during a package run leaves dpkg half-configured. Finishing
|
|
24
|
+
# that run is the standard safe repair: it is tried once, bounded, after
|
|
25
|
+
# the lock wait above, and only a database still inconsistent afterwards
|
|
26
|
+
# needs a person.
|
|
27
|
+
- name: Repair an interrupted package run once
|
|
28
|
+
when: dpkg_audit.stdout | trim != ''
|
|
29
|
+
block:
|
|
30
|
+
- name: Finish configuring every unpacked package, bounded
|
|
31
|
+
ansible.builtin.command:
|
|
32
|
+
argv: [timeout, '600', dpkg, --configure, -a]
|
|
33
|
+
environment:
|
|
34
|
+
DEBIAN_FRONTEND: noninteractive
|
|
35
|
+
- name: Inspect package database consistency after the repair
|
|
36
|
+
ansible.builtin.command: dpkg --audit
|
|
37
|
+
changed_when: false
|
|
38
|
+
register: dpkg_reaudit
|
|
39
|
+
rescue:
|
|
40
|
+
- name: Report a package run the repair could not finish
|
|
41
|
+
ansible.builtin.fail:
|
|
42
|
+
msg: APT_STATE_BROKEN; dpkg --configure -a did not finish. Inspect dpkg --audit and use reviewed package recovery. Never remove package lock files.
|
|
43
|
+
- name: Require a consistent package database before modifying packages
|
|
44
|
+
ansible.builtin.assert:
|
|
45
|
+
that: (dpkg_reaudit.stdout | default(dpkg_audit.stdout)) | trim == ''
|
|
46
|
+
fail_msg: APT_STATE_BROKEN; dpkg --configure -a did not clear dpkg --audit. Inspect it and use reviewed package recovery. Never remove package lock files.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
# This worker's GitHub sign-in, apart from its Vault renewal: a failure of one
|
|
3
|
+
# never hides or stops the other. The job signs in from the stored credential
|
|
4
|
+
# and adopts a rotated token while the old one still works.
|
|
5
|
+
- name: Declare the GitHub sign-in job
|
|
6
|
+
vars:
|
|
7
|
+
# Every step the job runs (the secret read and four gh calls) may take
|
|
8
|
+
# its full timeout (GITHUB_STEP_TIMEOUT_MS), plus the runtime's start.
|
|
9
|
+
github_step_timeout_seconds: 30
|
|
10
|
+
github_job_steps: 5
|
|
11
|
+
github_job_start_seconds: 30
|
|
12
|
+
ansible.builtin.copy:
|
|
13
|
+
dest: '{{ development_home }}/.config/systemd/user/personal-worker-github.service'
|
|
14
|
+
owner: '{{ development_user }}'
|
|
15
|
+
mode: '0600'
|
|
16
|
+
content: |
|
|
17
|
+
[Unit]
|
|
18
|
+
Description=Sign this worker in to GitHub from its stored credential
|
|
19
|
+
[Service]
|
|
20
|
+
Type=oneshot
|
|
21
|
+
WorkingDirectory={{ development_source }}
|
|
22
|
+
ExecStart={{ development_home }}/.local/bin/mise exec -- bun bootstrap/worker-github.ts
|
|
23
|
+
Environment="PATH={{ development_home }}/.local/share/mise/shims:{{ development_home }}/.local/bin:/usr/local/bin:/usr/bin:/bin"
|
|
24
|
+
UMask=0077
|
|
25
|
+
TimeoutStartSec={{ github_step_timeout_seconds * github_job_steps + github_job_start_seconds }}
|
|
26
|
+
- name: Declare the GitHub sign-in schedule
|
|
27
|
+
ansible.builtin.copy:
|
|
28
|
+
dest: '{{ development_home }}/.config/systemd/user/personal-worker-github.timer'
|
|
29
|
+
owner: '{{ development_user }}'
|
|
30
|
+
mode: '0600'
|
|
31
|
+
content: |
|
|
32
|
+
[Unit]
|
|
33
|
+
Description=Keep this worker signed in to GitHub with the stored credential
|
|
34
|
+
[Timer]
|
|
35
|
+
OnBootSec=2min
|
|
36
|
+
OnUnitActiveSec=10min
|
|
37
|
+
Persistent=true
|
|
38
|
+
[Install]
|
|
39
|
+
WantedBy=timers.target
|
|
40
|
+
- name: Enable this user's GitHub sign-in timer
|
|
41
|
+
become: true
|
|
42
|
+
become_user: '{{ development_user }}'
|
|
43
|
+
environment:
|
|
44
|
+
XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
|
|
45
|
+
ansible.builtin.systemd_service:
|
|
46
|
+
scope: user
|
|
47
|
+
name: personal-worker-github.timer
|
|
48
|
+
daemon_reload: true
|
|
49
|
+
enabled: true
|
|
50
|
+
state: started
|