@crvouga/dev-fleet 0.8.0 → 0.9.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 (204) hide show
  1. package/README.md +65 -9
  2. package/agents/audit.md +18 -18
  3. package/ansible/agents/agents.yml +134 -0
  4. package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
  5. package/ansible/agents/templates/claude.settings.json.j2 +3 -0
  6. package/ansible/agents/templates/codex.config.toml.j2 +11 -0
  7. package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
  8. package/ansible/agents/templates/grok.config.toml.j2 +19 -0
  9. package/ansible/agents/templates/opencode.json.j2 +24 -0
  10. package/ansible/agents/templates/receipt.json.j2 +10 -0
  11. package/ansible/ansible.cfg +14 -0
  12. package/ansible/github/git.yml +82 -0
  13. package/ansible/macos/manager-privilege.yml +55 -9
  14. package/ansible/macos/manager.yml +154 -7
  15. package/ansible/macos/policy.json +13 -0
  16. package/ansible/macos/t3-policy.yml +5 -3
  17. package/ansible/macos/vault-renew.yml +85 -0
  18. package/ansible/monitoring/agent-darwin.yml +3 -0
  19. package/ansible/monitoring/agent.yml +2 -0
  20. package/ansible/monitoring/hub-darwin.yml +3 -0
  21. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  22. package/ansible/net/remote-probe.sh +4 -1
  23. package/ansible/platforms.json +2 -2
  24. package/ansible/pyproject.toml +5 -0
  25. package/ansible/ubuntu/baseline.yml +39 -13
  26. package/ansible/ubuntu/disk-gc.py +6 -2
  27. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  28. package/ansible/ubuntu/policy.json +125 -6
  29. package/ansible/ubuntu/reboot.yml +89 -0
  30. package/ansible/ubuntu/recovery.yml +13 -3
  31. package/ansible/ubuntu/t3/policy.json +3 -0
  32. package/ansible/ubuntu/t3-desktop.yml +23 -2
  33. package/ansible/ubuntu/t3-headless.yml +128 -0
  34. package/ansible/ubuntu/t3-health.sh +24 -10
  35. package/ansible/ubuntu/t3-idle.py +37 -2
  36. package/ansible/ubuntu/t3-policy.yml +4 -1
  37. package/ansible/ubuntu/t3-route.yml +1 -0
  38. package/ansible/ubuntu/tailscale.yml +54 -8
  39. package/ansible/ubuntu/tasks/browser.yml +3 -0
  40. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  41. package/ansible/ubuntu/tasks/docker.yml +53 -6
  42. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  43. package/ansible/ubuntu/tasks/features.yml +22 -0
  44. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  45. package/ansible/ubuntu/tasks/install.yml +2 -1
  46. package/ansible/ubuntu/tasks/packages.yml +4 -2
  47. package/ansible/ubuntu/tasks/release.yml +6 -0
  48. package/ansible/ubuntu/tasks/resources.yml +6 -4
  49. package/ansible/ubuntu/tasks/services.yml +2 -2
  50. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  51. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  52. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  53. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  54. package/ansible/ubuntu/tasks/user.yml +1 -0
  55. package/ansible/ubuntu/worker-account.yml +117 -0
  56. package/ansible/uv.lock +265 -0
  57. package/bin/devfleet.mjs +42 -4
  58. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  59. package/bootstrap/global-config.ts +29 -3
  60. package/bootstrap/package-smoke.ts +127 -0
  61. package/bootstrap/pins.ts +189 -0
  62. package/bootstrap/release-notes.ts +88 -0
  63. package/bootstrap/release-version.ts +41 -7
  64. package/bootstrap/worker-features.ts +114 -0
  65. package/bootstrap/worker-seed.yaml +7 -3
  66. package/bootstrap/worker-tools.ts +39 -9
  67. package/cliproxy/README.md +1 -1
  68. package/cliproxy/docs/health-workflow.example.yml +20 -143
  69. package/docs/authentication.md +19 -11
  70. package/docs/consumer-setup.md +42 -27
  71. package/docs/convergence.md +67 -0
  72. package/docs/declared.md +61 -34
  73. package/docs/machine-setup.md +73 -7
  74. package/docs/manager-setup.md +10 -3
  75. package/docs/operations.md +64 -9
  76. package/docs/releasing.md +49 -1
  77. package/docs/runbooks.md +98 -7
  78. package/docs/state.md +23 -11
  79. package/docs/support-matrix.md +40 -4
  80. package/docs/topology.md +67 -6
  81. package/docs/ubuntu-sync.md +78 -28
  82. package/docs/worker-contract.md +122 -19
  83. package/docs/worker-requirements.md +107 -0
  84. package/examples/fleet.json +1 -0
  85. package/examples/workers-from-infrastructure/main.tf +132 -0
  86. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  87. package/llms.txt +3 -1
  88. package/mise.lock +29 -0
  89. package/mise.toml +5 -3
  90. package/package.json +2 -2
  91. package/schema/check-ids.json +1967 -170
  92. package/schema/check-ids.schema.json +34 -0
  93. package/schema/fleet.schema.json +327 -8
  94. package/schema/output/check.schema.json +1 -1
  95. package/schema/output/fleet-state-definition.schema.json +14 -0
  96. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  97. package/schema/output/fleet-sync.schema.json +18 -0
  98. package/schema/output/report.schema.json +3 -2
  99. package/schema/worker-record.schema.json +76 -0
  100. package/src/agents/converge.ts +140 -24
  101. package/src/agents/files.ts +2 -32
  102. package/src/agents/native-verify.ts +34 -3
  103. package/src/agents/secrets.ts +39 -4
  104. package/src/check-owners.ts +61 -2
  105. package/src/check-registry.ts +630 -0
  106. package/src/cli/agent-guide.ts +3 -3
  107. package/src/cli/cli.ts +4 -0
  108. package/src/cli/fleet-manifest.ts +9 -3
  109. package/src/cli/fleet-node-lifecycle.ts +313 -64
  110. package/src/cli/fleet-node-park.ts +238 -0
  111. package/src/cli/fleet-node-seed.ts +51 -35
  112. package/src/cli/fleet-state.ts +2 -1
  113. package/src/cli/fleet-sync-checkout.ts +7 -0
  114. package/src/cli/fleet-sync-enrollment.ts +193 -32
  115. package/src/cli/fleet-sync-forward.ts +4 -0
  116. package/src/cli/fleet-sync-manager.ts +103 -33
  117. package/src/cli/fleet-sync-output.ts +31 -11
  118. package/src/cli/fleet-sync-plan.ts +65 -9
  119. package/src/cli/fleet-sync-records.ts +113 -0
  120. package/src/cli/fleet-sync-remote.ts +151 -9
  121. package/src/cli/fleet-sync-schedule.ts +14 -4
  122. package/src/cli/fleet-sync-state.ts +28 -16
  123. package/src/cli/fleet-sync-vault.ts +77 -0
  124. package/src/cli/fleet-sync-worker.ts +214 -0
  125. package/src/cli/fleet-sync.ts +150 -19
  126. package/src/cli/fleet-validate.ts +59 -21
  127. package/src/cli/fleet-watch.ts +7 -1
  128. package/src/cli/fleet.ts +87 -5
  129. package/src/cli/managed-health.ts +25 -1
  130. package/src/cli/register-fleet.ts +115 -4
  131. package/src/cli/registry.ts +14 -0
  132. package/src/cli/tailnet.ts +43 -10
  133. package/src/cli/ui-data.ts +3 -1
  134. package/src/cli/worker-usage-report.ts +63 -0
  135. package/src/clock.ts +42 -0
  136. package/src/definition.ts +332 -9
  137. package/src/diagnostics.ts +5 -2
  138. package/src/exposure.ts +102 -0
  139. package/src/fleet-config.ts +3 -1
  140. package/src/git-identity.ts +76 -3
  141. package/src/ide/ide.ts +5 -0
  142. package/src/ide/t3.ts +12 -2
  143. package/src/input-hashes.ts +6 -0
  144. package/src/machine/machine-class.ts +59 -0
  145. package/src/machine/platform.ts +90 -11
  146. package/src/machine/state.ts +37 -15
  147. package/src/machine/support-matrix.ts +11 -9
  148. package/src/machine-health.ts +28 -5
  149. package/src/manager-health.ts +148 -2
  150. package/src/manager-sync.ts +65 -4
  151. package/src/monitoring/beszel.ts +8 -8
  152. package/src/monitoring/enroll.ts +8 -1
  153. package/src/monitoring/hub-access.ts +10 -4
  154. package/src/monitoring/systems.ts +138 -0
  155. package/src/network-definition.ts +13 -4
  156. package/src/node-converge-linux.ts +136 -27
  157. package/src/node-converge.ts +13 -3
  158. package/src/platform/ansible-receipt.ts +17 -2
  159. package/src/platform/ansible.ts +40 -4
  160. package/src/platform/manager-host.ts +25 -8
  161. package/src/platform/resource-verify.ts +18 -7
  162. package/src/platform/t3-policy.ts +24 -10
  163. package/src/platform/t3-service.ts +109 -34
  164. package/src/platform/ubuntu-release.ts +24 -0
  165. package/src/platform/ubuntu-verify.ts +31 -3
  166. package/src/platform/ubuntu-worker.ts +58 -0
  167. package/src/platform/worker-platform.ts +72 -0
  168. package/src/protocol.ts +1 -1
  169. package/src/public-reachability.ts +186 -0
  170. package/src/remote-bootstrap.ts +50 -12
  171. package/src/remote-operation.ts +18 -4
  172. package/src/repair-plan.ts +23 -12
  173. package/src/report.ts +2 -1
  174. package/src/schedule-check.ts +21 -2
  175. package/src/secrets.ts +4 -0
  176. package/src/service-auth.ts +55 -48
  177. package/src/ssh-connection.ts +141 -0
  178. package/src/ssh.ts +63 -24
  179. package/src/state/store.ts +4 -2
  180. package/src/state/sync.ts +35 -6
  181. package/src/state/workers.ts +140 -0
  182. package/src/t3/pairing.ts +59 -0
  183. package/src/t3/peer-link.ts +333 -0
  184. package/src/t3/version.ts +1 -1
  185. package/src/tailnet-cleanup.ts +185 -0
  186. package/src/tailnet-control.ts +346 -0
  187. package/src/tailscale.ts +1 -1
  188. package/src/toolchain-checks.ts +11 -1
  189. package/src/types.ts +4 -2
  190. package/src/worker-account.ts +63 -0
  191. package/src/worker-activity.ts +303 -0
  192. package/src/worker-capacity.ts +31 -0
  193. package/src/worker-exposure.ts +388 -0
  194. package/src/worker-features.ts +201 -0
  195. package/src/worker-host-checks.ts +71 -20
  196. package/src/worker-manifest.ts +27 -4
  197. package/src/worker-parking.ts +70 -0
  198. package/src/worker-policy.ts +17 -1
  199. package/src/worker-readiness.ts +15 -3
  200. package/src/worker-reboot.ts +129 -0
  201. package/src/worker-records.ts +305 -0
  202. package/src/worker-seed.ts +16 -11
  203. package/src/worker-sizing.ts +76 -0
  204. package/src/cli/schedule-logs.ts +0 -36
@@ -5,9 +5,56 @@
5
5
  "release": "26.04",
6
6
  "codename": "resolute",
7
7
  "architectures": ["amd64", "arm64"]
8
+ },
9
+ {
10
+ "release": "24.04",
11
+ "codename": "noble",
12
+ "architectures": ["amd64", "arm64"],
13
+ "packages": {
14
+ "sudo": "1.9.15p5-3ubuntu5.24.04.4",
15
+ "openssh-server": "1:9.6p1-3ubuntu13.20",
16
+ "dconf-cli": "0.40.0-4ubuntu0.1",
17
+ "docker.io": "29.1.3-0ubuntu3~24.04.2",
18
+ "docker-compose-v2": "2.40.3+ds1-0ubuntu1~24.04.1",
19
+ "docker-buildx": "0.30.1-0ubuntu1~24.04.1",
20
+ "build-essential": "12.10ubuntu1",
21
+ "ca-certificates": "20260816ubuntu0.24.04.2",
22
+ "curl": "8.5.0-2ubuntu10.16",
23
+ "git": "1:2.43.0-1ubuntu7.4",
24
+ "python3": "3.12.3-0ubuntu2.1",
25
+ "unzip": "6.0-28ubuntu4.1",
26
+ "xz-utils": "5.6.1+really5.4.5-1ubuntu0.4",
27
+ "ansible-core": "2.16.3-0ubuntu2",
28
+ "systemd-oomd": "255.4-1ubuntu8.18",
29
+ "nftables": "1.0.9-1ubuntu0.1"
30
+ },
31
+ "browserPackages": {
32
+ "fonts-liberation": "1:2.1.5-3",
33
+ "libasound2t64": "1.2.11-1ubuntu0.3",
34
+ "libatk-bridge2.0-0t64": "2.52.0-1build1",
35
+ "libatk1.0-0t64": "2.52.0-1build1",
36
+ "libatspi2.0-0t64": "2.52.0-1build1",
37
+ "libcairo2": "1.18.0-3build1",
38
+ "libcups2t64": "2.4.7-1.2ubuntu7.14",
39
+ "libdbus-1-3": "1.14.10-4ubuntu4.1",
40
+ "libdrm2": "2.4.125-1ubuntu0.1~24.04.2",
41
+ "libgbm1": "25.2.8-0ubuntu0.24.04.4",
42
+ "libglib2.0-0t64": "2.80.0-6ubuntu3.10",
43
+ "libnspr4": "2:4.35-1.1build1",
44
+ "libnss3": "2:3.98-1ubuntu0.3",
45
+ "libpango-1.0-0": "1.52.1+ds-1build1",
46
+ "libx11-6": "2:1.8.7-1ubuntu0.1",
47
+ "libxcb1": "1.15-1ubuntu2",
48
+ "libxcomposite1": "1:0.4.5-1build3",
49
+ "libxdamage1": "1:1.1.6-1build1",
50
+ "libxext6": "2:1.3.4-1build2",
51
+ "libxfixes3": "1:6.0.0-2build1",
52
+ "libxkbcommon0": "1.6.0-1build1",
53
+ "libxrandr2": "2:1.5.2-2build1"
54
+ }
8
55
  }
9
56
  ],
10
- "snapshot": "20261101T000000Z",
57
+ "snapshot": "20261108T000000Z",
11
58
  "packages": {
12
59
  "sudo": "1.9.17p2-1ubuntu3.2",
13
60
  "openssh-server": "1:10.2p1-2ubuntu3.8",
@@ -23,19 +70,81 @@
23
70
  "unzip": "6.0-29ubuntu1",
24
71
  "xz-utils": "5.8.3-1ubuntu0.1",
25
72
  "ansible-core": "2.20.1-1",
26
- "systemd-oomd": "259.9-0ubuntu0.1"
73
+ "systemd-oomd": "259.9-0ubuntu0.1",
74
+ "nftables": "1.1.6-1"
27
75
  },
28
76
  "tailscale": {
29
77
  "version": "1.104.5",
30
78
  "keySha256": "3e03dacf222698c60b8e2f990b809ca1b3e104de127767864284e6c228f1fb39"
31
79
  },
32
80
  "aptLockTimeoutSeconds": 120,
33
- "minimumFreeDiskMiB": 4096,
34
81
  "minimumFreeInodes": 10000,
35
82
  "t3RestartMaxDeferralHours": 24,
36
83
  "alwaysOnServices": ["ssh", "tailscaled", "docker", "beszel-agent"],
84
+ "features": {
85
+ "containers": {
86
+ "packages": ["docker.io", "docker-compose-v2", "docker-buildx"],
87
+ "services": ["docker"],
88
+ "groups": ["docker"],
89
+ "tools": ["docker-cli"]
90
+ },
91
+ "desktop": {
92
+ "packages": ["dconf-cli"]
93
+ }
94
+ },
95
+ "sizing": {
96
+ "minimumWorker": {
97
+ "memoryMiB": 3584,
98
+ "diskMiB": 20480,
99
+ "cpus": 2
100
+ },
101
+ "footprintMiB": {
102
+ "baseTools": 5120,
103
+ "checkouts": 1024,
104
+ "profiles": { "web": 256, "go": 1024, "rust": 2048, "jvm": 640 }
105
+ },
106
+ "agentSessionMiB": 1024,
107
+ "t3ReservedMiB": 1536,
108
+ "thresholds": {
109
+ "swapFile": {
110
+ "of": "memory",
111
+ "share": 0.5,
112
+ "minMiB": 1024,
113
+ "maxMiB": 8192
114
+ },
115
+ "minimumFreeDisk": {
116
+ "of": "disk",
117
+ "share": 0.05,
118
+ "minMiB": 2048,
119
+ "maxMiB": 4096
120
+ },
121
+ "diskGuardFloor": {
122
+ "of": "disk",
123
+ "share": 0.03,
124
+ "minMiB": 1024,
125
+ "maxMiB": 3072
126
+ },
127
+ "diskGcPressure": {
128
+ "of": "disk",
129
+ "share": 0.15,
130
+ "minMiB": 4096,
131
+ "maxMiB": 15360
132
+ },
133
+ "capacityWarn": {
134
+ "of": "disk",
135
+ "share": 0.1,
136
+ "minMiB": 3072,
137
+ "maxMiB": 10240
138
+ },
139
+ "capacityFail": {
140
+ "of": "disk",
141
+ "share": 0.02,
142
+ "minMiB": 1024,
143
+ "maxMiB": 2048
144
+ }
145
+ }
146
+ },
37
147
  "resourceSafety": {
38
- "swapFileMiB": 8192,
39
148
  "userMemoryMax": "90%",
40
149
  "agentMemoryMax": "30%",
41
150
  "agentSwapMax": "2G",
@@ -44,11 +153,9 @@
44
153
  "tmpMaxSize": "25%",
45
154
  "tmpAge": "2d",
46
155
  "varTmpAge": "14d",
47
- "diskGuardFloorMiB": 3072,
48
156
  "diskGuardWriteMiB": 32,
49
157
  "diskGuardIntervalSeconds": 5,
50
158
  "diskGcIdleDays": 7,
51
- "diskGcPressureMiB": 15360,
52
159
  "diskGcPressureIdleHours": 12,
53
160
  "diskGcDockerAgeHours": 168,
54
161
  "diskGcPressureDockerAgeHours": 24,
@@ -72,6 +179,18 @@
72
179
  "failOffsetSeconds": 30
73
180
  },
74
181
  "rebootPendingFailDays": 14,
182
+ "exposure": {
183
+ "tailnetInterface": "tailscale0",
184
+ "tailscalePort": 41641,
185
+ "containerInterfaces": ["docker0", "br-*"],
186
+ "systemListeners": [
187
+ { "protocol": "udp", "port": 68 },
188
+ { "protocol": "udp", "port": 546 },
189
+ { "protocol": "udp", "port": 5353 },
190
+ { "protocol": "udp", "port": 5355 },
191
+ { "protocol": "tcp", "port": 5355 }
192
+ ]
193
+ },
75
194
  "dockerSmokeTest": {
76
195
  "image": "busybox:1.37.0@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec",
77
196
  "timeoutSeconds": 60
@@ -0,0 +1,89 @@
1
+ ---
2
+ # One worker rebooted from the manager, only while its T3 runs no agent turn
3
+ # (a reboot ends every running turn), then waited for until it answers again.
4
+ # fleet-sync applies it for a worker's first converge, which nothing can be
5
+ # running on yet, and for --maintenance-reboot, one worker at a time. After
6
+ # it returns, fleet-sync converges the worker again to verify it is ready.
7
+ # Variables: target (the SSH alias, or the declared tailnet name), login
8
+ # (empty for the alias's own user), ssh_options (the SSH options every session
9
+ # of the manager takes, src/ssh-connection.ts), home (the account's home
10
+ # directory),
11
+ # revision (the checkout whose t3-idle.py is asked), reason (first-converge
12
+ # or maintenance) and force (reboot even while agent turns run).
13
+ - name: Address the worker
14
+ hosts: localhost
15
+ connection: local
16
+ gather_facts: false
17
+ tasks:
18
+ - name: Check the request
19
+ ansible.builtin.assert:
20
+ that:
21
+ - "target is match('^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$')"
22
+ - ssh_options is string and ssh_options is match('^-o ')
23
+ - "home is match('^/[A-Za-z0-9._/-]{1,4000}$')"
24
+ - "revision is match('^[a-f0-9]{40}$')"
25
+ - reason in ['first-converge', 'maintenance']
26
+ - name: Address the worker by its alias, with strict host keys
27
+ ansible.builtin.add_host:
28
+ name: '{{ target }}'
29
+ groups: [fleet_reboot]
30
+ ansible_python_interpreter: /usr/bin/python3
31
+ ansible_ssh_args: -o ControlMaster=no -o ControlPath=none
32
+ ansible_ssh_common_args: '{{ ssh_options }}'
33
+ worker_home: '{{ home }}'
34
+ worker_revision: '{{ revision }}'
35
+ reboot_reason: '{{ reason }}'
36
+ reboot_force: '{{ force | default(false) | bool }}'
37
+ changed_when: false
38
+ when: login | default('') == ''
39
+ # A worker whose account fleet-sync created this run, over an alias that
40
+ # still logs in as root.
41
+ - name: Address the worker by its alias as the declared account
42
+ ansible.builtin.add_host:
43
+ name: '{{ target }}'
44
+ groups: [fleet_reboot]
45
+ ansible_python_interpreter: /usr/bin/python3
46
+ ansible_ssh_args: -o ControlMaster=no -o ControlPath=none
47
+ ansible_ssh_common_args: '{{ ssh_options }}'
48
+ ansible_user: '{{ login }}'
49
+ worker_home: '{{ home }}'
50
+ worker_revision: '{{ revision }}'
51
+ reboot_reason: '{{ reason }}'
52
+ reboot_force: '{{ force | default(false) | bool }}'
53
+ changed_when: false
54
+ when: login | default('') is match('^[a-z_][a-z0-9_-]{0,31}$')
55
+
56
+ - name: Reboot the worker while it runs no agent turn
57
+ hosts: fleet_reboot
58
+ gather_facts: false
59
+ serial: 1
60
+ tasks:
61
+ - name: Find the state T3 records its runs in
62
+ ansible.builtin.stat:
63
+ path: '{{ worker_home }}/.t3/userdata/statev2.sqlite'
64
+ register: t3_state
65
+ - name: Ask T3 whether an agent turn is running
66
+ ansible.builtin.command:
67
+ argv:
68
+ - python3
69
+ - -I
70
+ - -B
71
+ - '{{ worker_home }}/.local/share/personal/fleet/revisions/{{ worker_revision }}/ansible/ubuntu/t3-idle.py'
72
+ - --state
73
+ - '{{ worker_home }}/.t3/userdata/statev2.sqlite'
74
+ register: t3_idle
75
+ changed_when: false
76
+ check_mode: false
77
+ failed_when: false
78
+ when: t3_state.stat.exists
79
+ # A worker that never ran T3 has no turn to end. One that cannot tell
80
+ # counts as busy: a reboot never ends a turn on a guess.
81
+ - name: Refuse while an agent turn runs
82
+ ansible.builtin.assert:
83
+ that: not t3_state.stat.exists or t3_idle.rc == 0 or reboot_force | bool
84
+ fail_msg: WORKER_REBOOT_BUSY; an agent turn is running on the worker, so it was not rebooted.
85
+ - name: Reboot, and wait for the worker to answer again
86
+ become: true
87
+ ansible.builtin.reboot:
88
+ msg: dev-fleet {{ reboot_reason }} reboot
89
+ reboot_timeout: 900
@@ -7,11 +7,21 @@
7
7
  # fleet-repair re-applies this play alone for worker:service-recovery.
8
8
  tags: [recovery]
9
9
  tasks:
10
- - name: Verify the desktop unit runs before adding any recovery trigger
10
+ # t3_unit is t3code-headless.service on a headless worker.
11
+ - name: Verify the T3 unit runs before adding any recovery trigger
11
12
  ansible.builtin.command:
12
- argv: [systemctl, --user, is-active, t3code-desktop.service]
13
+ argv:
14
+ [
15
+ systemctl,
16
+ --user,
17
+ is-active,
18
+ "{{ t3_unit | default('t3code-desktop.service') }}",
19
+ ]
20
+ become: true
13
21
  become_user: '{{ development_user }}'
14
22
  environment:
15
23
  XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
16
24
  changed_when: false
17
- - ansible.builtin.import_tasks: tasks/recovery.yml
25
+ check_mode: false
26
+ - name: Enable recovery
27
+ ansible.builtin.import_tasks: tasks/recovery.yml
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "organization": "Personal developer fleet",
4
+ "settings": {
5
+ "linkedEnvironmentsEnabled": { "value": true, "locked": true }
6
+ },
4
7
  "limits": {
5
8
  "updates": { "mode": "off", "allowNightly": true }
6
9
  }
@@ -52,6 +52,7 @@
52
52
  argv: [cat, '{{ t3_desktop_dir }}/.personal-fleet-version']
53
53
  register: t3_desktop_installed
54
54
  changed_when: false
55
+ check_mode: false
55
56
  failed_when: false
56
57
  - name: Check whether the desktop app is running before an update
57
58
  become: true
@@ -62,6 +63,7 @@
62
63
  argv: [systemctl, --user, is-active, --quiet, t3code-desktop.service]
63
64
  register: t3_desktop_was_active
64
65
  changed_when: false
66
+ check_mode: false
65
67
  failed_when: false
66
68
  - name: Unpack the pinned app when its version differs
67
69
  when: t3_desktop_installed.stdout | default('') != t3_desktop_name
@@ -193,7 +195,8 @@
193
195
  WantedBy=graphical-session.target
194
196
  # Repoint recovery now: a timer still running an older revision's script
195
197
  # would restart the headless server right after its approved migration.
196
- - ansible.builtin.import_tasks: tasks/recovery.yml
198
+ - name: Repoint recovery to this revision
199
+ ansible.builtin.import_tasks: tasks/recovery.yml
197
200
  - name: Enable the desktop app for every graphical login
198
201
  become: true
199
202
  become_user: '{{ development_user }}'
@@ -204,6 +207,23 @@
204
207
  name: t3code-desktop.service
205
208
  daemon_reload: true
206
209
  enabled: true
210
+ # A worker that was a headless worker (t3-headless.yml) keeps its data in
211
+ # ~/.t3; only the server's user service goes.
212
+ - name: Stop the T3 server of a worker that was a headless worker
213
+ become: true
214
+ become_user: '{{ development_user }}'
215
+ environment:
216
+ XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
217
+ ansible.builtin.systemd_service:
218
+ scope: user
219
+ name: t3code-headless.service
220
+ state: stopped
221
+ enabled: false
222
+ failed_when: false
223
+ - name: Remove the T3 server's user service
224
+ ansible.builtin.file:
225
+ path: '{{ development_home }}/.config/systemd/user/t3code-headless.service'
226
+ state: absent
207
227
  # One T3 process per worker: the desktop app. The former headless server
208
228
  # (`t3 service install`) is retired; absent on machines that never had it.
209
229
  - name: Retire the former headless T3 server
@@ -233,7 +253,8 @@
233
253
  # just installed, only while it runs no agent turn. Otherwise
234
254
  # personal-worker-recovery restarts it at the first idle check, and
235
255
  # t3:running-version reports T3_RESTART_PENDING until then.
236
- - ansible.builtin.import_tasks: tasks/t3-restart.yml
256
+ - name: Restart T3 when it is idle
257
+ ansible.builtin.import_tasks: tasks/t3-restart.yml
237
258
  when:
238
259
  - t3_desktop_was_active.rc == 0
239
260
  - t3_desktop_updated is changed
@@ -0,0 +1,128 @@
1
+ ---
2
+ # The worker's only T3 process on a headless worker (fleet.json
3
+ # features.session "headless"): the pinned native T3 server, which the release
4
+ # ships as its own archive beside the desktop package (mise.toml t3_version
5
+ # and t3_linux_*_sha256; bootstrap/native-t3.ts installs it in every worker's
6
+ # toolchain). `t3 serve` runs the same server the desktop app embeds, with no
7
+ # browser, display or GPU. It is a lingering user service: the designated user
8
+ # lingers (tasks/user.yml), so its user manager starts it at boot with nobody
9
+ # logged in. It listens on 127.0.0.1:3773 like the desktop app, so the private
10
+ # route, the policy, the provider instances, the recovery timer and the checks
11
+ # are the same for both session types. ~/.t3 is kept when a worker changes
12
+ # type. Starting or migrating is left to fleet-sync.
13
+ - name: Run the pinned T3 server as a lingering user service
14
+ hosts: localhost
15
+ connection: local
16
+ become: true
17
+ gather_facts: false
18
+ vars_files:
19
+ - policy.json
20
+ tasks:
21
+ - name: Require the pinned server from the worker's toolchain
22
+ ansible.builtin.stat:
23
+ path: '{{ t3_server_path }}'
24
+ register: t3_server_binary
25
+ - name: Refuse to declare a service for a server that is not installed
26
+ ansible.builtin.assert:
27
+ that:
28
+ - t3_server_binary.stat.exists
29
+ - t3_server_binary.stat.executable
30
+ fail_msg: T3_SERVER_INSTALL_FAILED; the pinned T3 server is not installed. Run fleet-sync, which installs the toolchain first.
31
+ - name: Check whether the server is running before an update
32
+ become: true
33
+ become_user: '{{ development_user }}'
34
+ environment:
35
+ XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
36
+ ansible.builtin.command:
37
+ argv: [systemctl, --user, is-active, --quiet, t3code-headless.service]
38
+ register: t3_headless_was_active
39
+ changed_when: false
40
+ check_mode: false
41
+ failed_when: false
42
+ # One T3 process per worker: a worker that was a desktop worker keeps its
43
+ # data in ~/.t3; only the app's session service goes.
44
+ - name: Stop the desktop app of a worker that was a desktop worker
45
+ become: true
46
+ become_user: '{{ development_user }}'
47
+ environment:
48
+ XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
49
+ ansible.builtin.systemd_service:
50
+ scope: user
51
+ name: t3code-desktop.service
52
+ state: stopped
53
+ enabled: false
54
+ failed_when: false
55
+ - name: Remove the desktop app's session service
56
+ ansible.builtin.file:
57
+ path: '{{ development_home }}/.config/systemd/user/t3code-desktop.service'
58
+ state: absent
59
+ - name: Create the user unit directory
60
+ ansible.builtin.file:
61
+ path: '{{ development_home }}/.config/systemd/user'
62
+ state: directory
63
+ owner: '{{ development_user }}'
64
+ mode: '0700'
65
+ - name: Declare the T3 server as a lingering user service
66
+ ansible.builtin.copy:
67
+ dest: '{{ development_home }}/.config/systemd/user/t3code-headless.service'
68
+ owner: '{{ development_user }}'
69
+ mode: '0600'
70
+ content: |
71
+ # Managed by personal fleet-sync: ansible/ubuntu/t3-headless.yml
72
+ [Unit]
73
+ Description=T3 Code server (headless)
74
+ StartLimitIntervalSec=300
75
+ StartLimitBurst=5
76
+ [Service]
77
+ Type=simple
78
+ WorkingDirectory=%h
79
+ Environment=T3CODE_HOME=%h/.t3
80
+ # /tmp is RAM with a per-user quota that agent and build caches
81
+ # exhaust; agents use disk-backed /var/tmp (aged by tasks/resources.yml).
82
+ Environment=TMPDIR=/var/tmp
83
+ ExecStart="{{ t3_server_path }}" serve --mode web --host 127.0.0.1 --port 3773 --base-dir %h/.t3 --no-browser
84
+ KillMode=mixed
85
+ Restart=always
86
+ RestartSec=5
87
+ CPUWeight=1000
88
+ IOWeight=1000
89
+ MemoryLow={{ sizing.t3ReservedMiB }}M
90
+ OOMScoreAdjust=100
91
+ ManagedOOMPreference=avoid
92
+ [Install]
93
+ WantedBy=default.target
94
+ register: t3_headless_unit
95
+ # Repoint recovery now: a timer still running an older revision's script
96
+ # would act on the unit of the other session type.
97
+ - name: Repoint recovery to this revision
98
+ ansible.builtin.import_tasks: tasks/recovery.yml
99
+ - name: Enable the T3 server for every boot, with nobody logged in
100
+ become: true
101
+ become_user: '{{ development_user }}'
102
+ environment:
103
+ XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
104
+ ansible.builtin.systemd_service:
105
+ scope: user
106
+ name: t3code-headless.service
107
+ daemon_reload: true
108
+ enabled: true
109
+ - name: Start the T3 server without restarting a running one
110
+ become: true
111
+ become_user: '{{ development_user }}'
112
+ environment:
113
+ XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
114
+ ansible.builtin.systemd_service:
115
+ scope: user
116
+ name: t3code-headless.service
117
+ state: started
118
+ # A running server keeps the files of its release: restart it into the
119
+ # one just declared, only while it runs no agent turn (as the desktop
120
+ # app). Otherwise personal-worker-recovery restarts it at the first idle
121
+ # check, and t3:running-version reports T3_RESTART_PENDING until then.
122
+ - name: Restart T3 when it is idle
123
+ ansible.builtin.import_tasks: tasks/t3-restart.yml
124
+ vars:
125
+ t3_unit: t3code-headless.service
126
+ when:
127
+ - t3_headless_was_active.rc == 0
128
+ - t3_headless_unit is changed
@@ -1,12 +1,16 @@
1
1
  #!/bin/sh
2
- # Keep the worker T3 desktop app (UI and server) up, responsive and on the
3
- # installed release. Run every minute by personal-worker-recovery.timer
4
- # (managed by fleet-sync, tasks/recovery.yml), from the stable copy the role
5
- # installs beside t3-idle.py.
6
- # - The former headless server is stopped and disabled: one T3 per worker.
7
- # - No graphical session: T3_NO_GRAPHICAL_SESSION; after three checks in a row
8
- # the display manager is restarted so auto-login starts one, three times at
9
- # most until a session is seen again.
2
+ # Keep the worker's T3 up, responsive and on the installed release: the
3
+ # desktop app (UI and server) on a desktop worker, the native server
4
+ # (t3code-headless.service, t3-headless.yml) on a headless one. Run every
5
+ # minute by personal-worker-recovery.timer (managed by fleet-sync,
6
+ # tasks/recovery.yml), from the stable copy the role installs beside
7
+ # t3-idle.py.
8
+ # - The former upstream server unit (t3code.service) is stopped and
9
+ # disabled: one T3 per worker.
10
+ # - Desktop only. No graphical session: T3_NO_GRAPHICAL_SESSION; after three
11
+ # checks in a row the display manager is restarted so auto-login starts
12
+ # one, three times at most until a session is seen again. A headless
13
+ # worker has no session to wait for.
10
14
  # - Stopped or failed: reset its start limit and start it.
11
15
  # - Running but its local API has not answered for three checks in a row:
12
16
  # restart it, since a hung server serves no turns.
@@ -15,6 +19,11 @@
15
19
  set -u
16
20
  url=http://127.0.0.1:3773/.well-known/t3/environment
17
21
  unit=t3code-desktop.service
22
+ headless=
23
+ if systemctl --user is-enabled --quiet t3code-headless.service; then
24
+ unit=t3code-headless.service
25
+ headless=1
26
+ fi
18
27
  runtime="${XDG_RUNTIME_DIR:?}"
19
28
  failures="$runtime/personal-t3-health-failures"
20
29
  no_session="$runtime/personal-t3-no-session"
@@ -40,7 +49,7 @@ if systemctl --user is-active --quiet t3code.service ||
40
49
  fi
41
50
  fi
42
51
 
43
- if ! systemctl --user is-active --quiet graphical-session.target; then
52
+ if [ -z "$headless" ] && ! systemctl --user is-active --quiet graphical-session.target; then
44
53
  rm -f "$failures"
45
54
  seen=$(count "$no_session")
46
55
  echo "T3_NO_GRAPHICAL_SESSION ($seen/$limit)."
@@ -78,7 +87,12 @@ fi
78
87
  rm -f "$failures"
79
88
 
80
89
  running=$(printf '%s' "$environment" | sed -n 's/.*"serverVersion" *: *"\([^"]*\)".*/\1/p')
81
- release=$(sed -n 's/^T3-Code-\(.*\)-[a-z0-9]*\.deb$/\1/p' "$installed" 2>/dev/null)
90
+ if [ -n "$headless" ]; then
91
+ # The release is the versioned directory the unit runs the server from.
92
+ release=$(systemctl --user show --property=ExecStart --value "$unit" | sed -n 's|.*/t3-\([^/ ]*\)-linux-[a-z0-9]*/t3 .*|\1|p' | head -n 1)
93
+ else
94
+ release=$(sed -n 's/^T3-Code-\(.*\)-[a-z0-9]*\.deb$/\1/p' "$installed" 2>/dev/null)
95
+ fi
82
96
  reason=
83
97
  [ -f "$pending" ] && reason="the staged policy"
84
98
  if [ -n "$release" ] && [ "$running" != "$release" ]; then
@@ -5,17 +5,29 @@ T3 offers no command that lists the running turns of every project, so this
5
5
  reads the runs T3 records in its own state database, read-only. Anything it
6
6
  cannot read counts as busy: a restart never ends a turn on a guess.
7
7
 
8
- Usage: t3-idle.py --state ~/.t3/userdata/statev2.sqlite
8
+ With --activity it prints one JSON line for fleet-status instead and exits 0:
9
+ how many agent turns are under way and when the last one was requested or
10
+ ended. Only the status and the times of a run are read, never its content.
11
+
12
+ Usage: t3-idle.py --state ~/.t3/userdata/statev2.sqlite [--activity]
9
13
  """
10
14
  import argparse
15
+ import json
11
16
  import sqlite3
12
17
  import sys
13
18
 
14
19
  IDLE, BUSY, UNKNOWN = 0, 1, 2
15
20
 
21
+ # The statuses of a turn that has not ended yet.
22
+ UNDER_WAY = ('queued', 'preparing', 'starting', 'running', 'waiting')
23
+
24
+
25
+ def connect(state):
26
+ return sqlite3.connect(f'file:{state}?mode=ro', uri=True, timeout=5)
27
+
16
28
 
17
29
  def running_turns(state):
18
- connection = sqlite3.connect(f'file:{state}?mode=ro', uri=True, timeout=5)
30
+ connection = connect(state)
19
31
  try:
20
32
  (count,) = connection.execute(
21
33
  "SELECT count(*) FROM orchestration_v2_projection_runs WHERE status = 'running'"
@@ -25,10 +37,33 @@ def running_turns(state):
25
37
  connection.close()
26
38
 
27
39
 
40
+ def activity(state):
41
+ connection = connect(state)
42
+ try:
43
+ marks = ','.join('?' for _ in UNDER_WAY)
44
+ (under_way,) = connection.execute(
45
+ f'SELECT count(*) FROM orchestration_v2_projection_runs WHERE status IN ({marks})',
46
+ UNDER_WAY,
47
+ ).fetchone()
48
+ (last,) = connection.execute(
49
+ 'SELECT max(coalesce(completed_at, requested_at)) FROM orchestration_v2_projection_runs'
50
+ ).fetchone()
51
+ return {'version': 1, 'running': under_way, 'lastActivity': last}
52
+ finally:
53
+ connection.close()
54
+
55
+
28
56
  def main():
29
57
  parser = argparse.ArgumentParser()
30
58
  parser.add_argument('--state', required=True)
59
+ parser.add_argument('--activity', action='store_true')
31
60
  args = parser.parse_args()
61
+ if args.activity:
62
+ try:
63
+ print(json.dumps(activity(args.state)))
64
+ except sqlite3.Error:
65
+ print(json.dumps({'version': 1, 'unknown': True}))
66
+ return 0
32
67
  try:
33
68
  count = running_turns(args.state)
34
69
  except sqlite3.Error:
@@ -53,6 +53,7 @@
53
53
  argv: [systemctl, --user, is-active, --quiet, t3code-desktop.service]
54
54
  register: t3_desktop_active
55
55
  changed_when: false
56
+ check_mode: false
56
57
  failed_when: false
57
58
  - name: Ask the running T3 which policy it loaded
58
59
  become: true
@@ -61,9 +62,11 @@
61
62
  argv: ['{{ t3_executable }}', policy, show]
62
63
  register: t3_policy_loaded
63
64
  changed_when: false
65
+ check_mode: false
64
66
  failed_when: false
65
67
  when: t3_desktop_active.rc == 0
66
- - ansible.builtin.import_tasks: tasks/t3-restart.yml
68
+ - name: Restart T3 when it is idle
69
+ ansible.builtin.import_tasks: tasks/t3-restart.yml
67
70
  when:
68
71
  - t3_desktop_active.rc == 0
69
72
  - t3_policy_loaded.stdout is not search('loaded this policy\.\s*$')
@@ -17,6 +17,7 @@
17
17
  argv: [tailscale, serve, status, --json]
18
18
  register: t3_serve_status
19
19
  changed_when: false
20
+ check_mode: false
20
21
  - name: Publish T3 on tailnet HTTPS port 443
21
22
  ansible.builtin.command:
22
23
  argv: [tailscale, serve, --bg, --https=443, '{{ t3_route_target }}']