@crvouga/dev-fleet 0.4.0 → 0.5.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 (38) hide show
  1. package/README.md +3 -1
  2. package/ansible/macos/policy.json +1 -0
  3. package/ansible/platforms.json +111 -0
  4. package/ansible/ubuntu/authorize-sudo.sh +7 -16
  5. package/ansible/ubuntu/baseline.yml +4 -28
  6. package/ansible/ubuntu/disk-gc.py +186 -4
  7. package/ansible/ubuntu/policy.json +18 -3
  8. package/ansible/ubuntu/t3-desktop.yml +2 -2
  9. package/ansible/ubuntu/tailscale.yml +20 -9
  10. package/ansible/ubuntu/tasks/dpkg.yml +46 -0
  11. package/ansible/ubuntu/tasks/install.yml +38 -0
  12. package/ansible/ubuntu/tasks/packages.yml +4 -55
  13. package/ansible/ubuntu/tasks/release.yml +25 -0
  14. package/ansible/ubuntu/tasks/resources.yml +6 -5
  15. package/ansible/ubuntu/tasks/snapshot.yml +50 -0
  16. package/ansible/ubuntu/tasks/unattended-upgrades.yml +24 -0
  17. package/bootstrap/fleet-remote-bootstrap.sh +18 -41
  18. package/docs/consumer-setup.md +32 -30
  19. package/docs/declared.md +4 -5
  20. package/docs/runbooks.md +3 -6
  21. package/docs/support-matrix.md +36 -0
  22. package/docs/ubuntu-sync.md +9 -7
  23. package/docs/worker-contract.md +28 -1
  24. package/package.json +2 -2
  25. package/schema/check-ids.json +8 -4
  26. package/schema/fleet.schema.json +7 -3
  27. package/src/cli/fleet-sync-remote.ts +1 -1
  28. package/src/definition.ts +18 -7
  29. package/src/machine/platform.ts +48 -28
  30. package/src/machine/support-matrix.ts +76 -0
  31. package/src/manager-sync.ts +15 -7
  32. package/src/network-definition.ts +1 -1
  33. package/src/node-converge.ts +1 -1
  34. package/src/platform/debian-version.ts +62 -0
  35. package/src/platform/ubuntu-audit.ts +11 -4
  36. package/src/platform/ubuntu-release.ts +41 -0
  37. package/src/remote-bootstrap.ts +2 -0
  38. package/src/worker-host-checks.ts +36 -10
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
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
3
  "minimumMajorVersion": 14,
4
+ "architectures": ["arm64", "x86_64"],
4
5
  "pmset": {
5
6
  "sleep": "0",
6
7
  "womp": "1"
@@ -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
- case "$(uname -s)" in
7
- Linux)
8
- . /etc/os-release
9
- [ "$ID" = ubuntu ] && [ "$VERSION_ID" = 26.04 ] || exit 1 ;;
10
- Darwin)
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
- if command -v flock >/dev/null 2>&1; then
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
- group=root
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
- - name: Require the supported common release and an explicit non-root user
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 platform or user; no distribution upgrade is permitted.
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
@@ -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 work, a container or
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, dry_run=args.dry_run,
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
- "release": "26.04",
4
- "architectures": ["amd64", "arm64"],
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 26.04 no longer ships and the
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
- - name: Require supported Ubuntu
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
- - name: Require maintenance for an installed version change
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 ansible_facts.packages.tailscale[0].version == tailscale.version"
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/resolute.noarmor.gpg
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 resolute main
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,38 @@
1
+ ---
2
+ # The one way a pinned Ubuntu package is installed: from the snapshot,
3
+ # waiting for the APT lock, and never downgrading. A pin is a minimum: a
4
+ # package Ubuntu's security updates moved past its pin is left as it is and
5
+ # reported as a warning (docs/worker-contract.md). `install_pins` maps each
6
+ # package to its pinned version.
7
+ - ansible.builtin.import_tasks: snapshot.yml
8
+ - name: Read the installed package versions
9
+ ansible.builtin.package_facts:
10
+ manager: apt
11
+ - name: Compare each installed pinned package with its pin
12
+ ansible.builtin.command:
13
+ argv:
14
+ - dpkg
15
+ - --compare-versions
16
+ - '{{ ansible_facts.packages[item][0].version }}'
17
+ - ge
18
+ - '{{ install_pins[item] }}'
19
+ loop: "{{ install_pins.keys() | select('in', ansible_facts.packages) | list }}"
20
+ register: pin_comparison
21
+ changed_when: false
22
+ failed_when: pin_comparison.rc not in [0, 1]
23
+ check_mode: false
24
+ - name: Select the pinned packages that are absent or older than their pin
25
+ ansible.builtin.set_fact:
26
+ below_pin: "{{ (install_pins.keys() | reject('in', ansible_facts.packages) | list) + (pin_comparison.results | selectattr('rc', 'equalto', 1) | map(attribute='item') | list) }}"
27
+ - name: Install the pinned version of each package below its pin, without restarting services
28
+ ansible.builtin.apt:
29
+ name: '{{ item }}={{ install_pins[item] }}'
30
+ state: present
31
+ lock_timeout: '{{ aptLockTimeoutSeconds }}'
32
+ allow_downgrade: false
33
+ allow_unauthenticated: false
34
+ fail_on_autoremove: true
35
+ policy_rc_d: 101
36
+ environment:
37
+ APT_CONFIG: /etc/personal-fleet/apt.conf
38
+ loop: '{{ below_pin }}'