@crvouga/dev-fleet 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +22 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/policy.json +11 -1
  4. package/ansible/monitoring/compose.yml +10 -0
  5. package/ansible/monitoring/container-watchdog.py +118 -0
  6. package/ansible/monitoring/hub-darwin.yml +146 -0
  7. package/ansible/net/remote-probe.sh +5 -0
  8. package/ansible/ubuntu/baseline.yml +2 -0
  9. package/ansible/ubuntu/disk-gc.py +25 -3
  10. package/ansible/ubuntu/disk-guard.py +24 -5
  11. package/ansible/ubuntu/policy.json +50 -1
  12. package/ansible/ubuntu/tasks/browser.yml +56 -0
  13. package/ansible/ubuntu/tasks/docker.yml +52 -0
  14. package/ansible/ubuntu/tasks/resources.yml +2 -2
  15. package/ansible/ubuntu/tasks/services.yml +13 -0
  16. package/bootstrap/doctor.ts +32 -6
  17. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  18. package/bootstrap/setup.ts +23 -8
  19. package/bootstrap/worker-tools.ts +118 -0
  20. package/cliproxy/README.md +1 -1
  21. package/cliproxy/compose.yaml +4 -0
  22. package/cliproxy/scripts/checkpoint.py +1 -1
  23. package/docs/authentication.md +90 -0
  24. package/docs/declared.md +2 -0
  25. package/docs/machine-setup.md +22 -12
  26. package/docs/operations.md +87 -31
  27. package/docs/runbooks.md +380 -0
  28. package/docs/state.md +13 -7
  29. package/docs/ubuntu-sync.md +7 -6
  30. package/examples/fleet.json +19 -1
  31. package/llms.txt +2 -0
  32. package/mise.toml +7 -0
  33. package/package.json +2 -2
  34. package/schema/check-ids.json +522 -0
  35. package/schema/fleet.schema.json +178 -1
  36. package/schema/output/check.schema.json +23 -0
  37. package/schema/output/error.schema.json +14 -0
  38. package/schema/output/fleet-report.schema.json +79 -0
  39. package/schema/output/fleet-sync.schema.json +112 -0
  40. package/schema/output/report.schema.json +38 -0
  41. package/src/auth-checks.ts +3 -0
  42. package/src/cli/agent-guide.ts +5 -3
  43. package/src/cli/fleet-report.ts +126 -0
  44. package/src/cli/fleet-sync-manager.ts +11 -0
  45. package/src/cli/fleet-sync-plan.ts +200 -0
  46. package/src/cli/fleet-sync-remote.ts +3 -1
  47. package/src/cli/fleet-sync-schedule.ts +142 -35
  48. package/src/cli/fleet-sync.ts +90 -8
  49. package/src/cli/fleet-watch.ts +198 -0
  50. package/src/cli/fleet.ts +4 -0
  51. package/src/cli/register-fleet.ts +51 -0
  52. package/src/cli/registry.ts +13 -0
  53. package/src/cli/schedule-logs.ts +36 -0
  54. package/src/clock.ts +63 -0
  55. package/src/definition.ts +142 -5
  56. package/src/git-identity.ts +160 -0
  57. package/src/machine-health.ts +51 -4
  58. package/src/manager-health.ts +205 -0
  59. package/src/manager-sync.ts +12 -2
  60. package/src/monitoring/hub-backup.ts +63 -17
  61. package/src/monitoring/manager-hub.ts +44 -2
  62. package/src/node-converge-linux.ts +16 -5
  63. package/src/notify.ts +239 -0
  64. package/src/remote-bootstrap.ts +87 -25
  65. package/src/remote-operation.ts +213 -0
  66. package/src/schedule-check.ts +40 -0
  67. package/src/secrets.ts +26 -8
  68. package/src/ssh.ts +10 -2
  69. package/src/state/history.ts +172 -0
  70. package/src/state/store.ts +11 -4
  71. package/src/tailscale.ts +51 -0
  72. package/src/toolchain-checks.ts +8 -3
  73. package/src/worker-capability-checks.ts +167 -0
  74. package/src/worker-capacity.ts +19 -7
  75. package/src/worker-host-checks.ts +32 -0
  76. package/src/worker-policy.ts +33 -0
  77. package/src/worker-readiness.ts +10 -1
@@ -0,0 +1,56 @@
1
+ ---
2
+ # Headless Chromium for browser tests (Playwright, Storybook, end-to-end
3
+ # suites): the system libraries it loads, at the snapshot's exact versions,
4
+ # and one pinned Chrome Headless Shell build. Applied to workers that select
5
+ # the web tool profile; the launch below backs worker:browser.
6
+ - name: Install the exact libraries headless Chromium loads
7
+ ansible.builtin.apt:
8
+ name: '{{ item.key }}={{ item.value }}'
9
+ state: present
10
+ lock_timeout: '{{ aptLockTimeoutSeconds }}'
11
+ allow_downgrade: false
12
+ allow_unauthenticated: false
13
+ fail_on_autoremove: true
14
+ policy_rc_d: 101
15
+ environment:
16
+ APT_CONFIG: /etc/personal-fleet/apt.conf
17
+ loop: '{{ browser.packages | dict2items }}'
18
+ - name: Name the headless shell build of this architecture
19
+ ansible.builtin.set_fact:
20
+ browser_build: "{{ browser.headlessShell['arm64' if ansible_facts.architecture == 'aarch64' else 'amd64'] }}"
21
+ browser_root: '/opt/personal-fleet/browser/{{ browser.headlessShell.version }}'
22
+ - name: Create the package cache and the browser directory
23
+ ansible.builtin.file:
24
+ path: '{{ item }}'
25
+ state: directory
26
+ owner: root
27
+ group: root
28
+ mode: '0755'
29
+ loop:
30
+ - /var/cache/personal-fleet
31
+ - '{{ browser_root }}'
32
+ - name: Download the pinned headless shell
33
+ ansible.builtin.get_url:
34
+ url: 'https://cdn.playwright.dev/builds/cft/{{ browser.headlessShell.version }}/{{ browser_build.archive }}.zip'
35
+ dest: '/var/cache/personal-fleet/chrome-headless-shell-{{ browser.headlessShell.version }}-{{ browser_build.archive | basename }}.zip'
36
+ checksum: 'sha256:{{ browser_build.sha256 }}'
37
+ mode: '0644'
38
+ timeout: 300
39
+ - name: Unpack the verified headless shell
40
+ ansible.builtin.unarchive:
41
+ src: '/var/cache/personal-fleet/chrome-headless-shell-{{ browser.headlessShell.version }}-{{ browser_build.archive | basename }}.zip'
42
+ dest: '{{ browser_root }}'
43
+ remote_src: true
44
+ creates: '{{ browser_root }}/{{ browser_build.archive | basename }}/chrome-headless-shell'
45
+ - name: Launch headless as the developer, load a blank page and exit (BROWSER_LAUNCH_FAILED)
46
+ ansible.builtin.command:
47
+ argv:
48
+ - '{{ browser_root }}/{{ browser_build.archive | basename }}/chrome-headless-shell'
49
+ - --headless
50
+ - --no-sandbox
51
+ - --disable-gpu
52
+ - --dump-dom
53
+ - about:blank
54
+ become_user: '{{ development_user }}'
55
+ timeout: '{{ browser.launchTimeoutSeconds }}'
56
+ changed_when: false
@@ -29,3 +29,55 @@
29
29
  - [docker, compose, version]
30
30
  - [docker, buildx, version]
31
31
  changed_when: false
32
+ # The engine answering is not proof that an agent can use it: run a pinned
33
+ # image and build a one-line image as the developer, in a fresh session that
34
+ # reads the docker group from scratch. The image is cached here so the same
35
+ # test works offline in worker:docker-run.
36
+ - name: Look for the cached smoke-test image
37
+ ansible.builtin.command:
38
+ argv:
39
+ [
40
+ docker,
41
+ image,
42
+ inspect,
43
+ --format,
44
+ '{{ "{{.Id}}" }}',
45
+ '{{ dockerSmokeTest.image }}',
46
+ ]
47
+ become_user: '{{ development_user }}'
48
+ check_mode: false
49
+ changed_when: false
50
+ failed_when: false
51
+ register: docker_smoke_image
52
+ - name: Cache the pinned smoke-test image
53
+ ansible.builtin.command:
54
+ argv: [docker, pull, --quiet, '{{ dockerSmokeTest.image }}']
55
+ become_user: '{{ development_user }}'
56
+ when: docker_smoke_image.rc | default(0) != 0
57
+ changed_when: true
58
+ - name: Run a container as the developer, offline (DOCKER_RUN_FAILED)
59
+ ansible.builtin.command:
60
+ argv:
61
+ [
62
+ docker,
63
+ run,
64
+ --rm,
65
+ --network,
66
+ none,
67
+ --pull,
68
+ never,
69
+ '{{ dockerSmokeTest.image }}',
70
+ 'true',
71
+ ]
72
+ become_user: '{{ development_user }}'
73
+ timeout: '{{ dockerSmokeTest.timeoutSeconds }}'
74
+ changed_when: false
75
+ - name: Build a one-line image as the developer, offline (DOCKER_BUILD_FAILED)
76
+ ansible.builtin.command:
77
+ argv: [docker, build, --quiet, --pull=false, --network, none, '-']
78
+ stdin: |
79
+ FROM {{ dockerSmokeTest.image }}
80
+ RUN true
81
+ become_user: '{{ development_user }}'
82
+ timeout: '{{ dockerSmokeTest.timeoutSeconds }}'
83
+ changed_when: false
@@ -224,7 +224,7 @@
224
224
  StartLimitIntervalSec=0
225
225
  [Service]
226
226
  Type=simple
227
- ExecStart=/usr/bin/python3 -I -B {{ disk_guard_source.stat.path }} --path / --path {{ development_home }} --floor-mib {{ resourceSafety.diskGuardFloorMiB }} --write-mib {{ resourceSafety.diskGuardWriteMiB }} --interval {{ resourceSafety.diskGuardIntervalSeconds }}
227
+ ExecStart=/usr/bin/python3 -I -B {{ disk_guard_source.stat.path }} --path / --path {{ development_home }} --floor-mib {{ resourceSafety.diskGuardFloorMiB }} --inode-floor-percent {{ resourceSafety.inodeFailFreePercent }} --write-mib {{ resourceSafety.diskGuardWriteMiB }} --interval {{ resourceSafety.diskGuardIntervalSeconds }}
228
228
  Restart=always
229
229
  RestartSec=5
230
230
  OOMScoreAdjust=-1000
@@ -276,7 +276,7 @@
276
276
  After=docker.service
277
277
  [Service]
278
278
  Type=oneshot
279
- ExecStart=/usr/bin/python3 -I -B {{ disk_gc_source.stat.path }} --home {{ development_home }} --path / --path {{ development_home }} --idle-days {{ resourceSafety.diskGcIdleDays }} --pressure-mib {{ resourceSafety.diskGcPressureMiB }} --pressure-idle-hours {{ resourceSafety.diskGcPressureIdleHours }} --docker-age-hours {{ resourceSafety.diskGcDockerAgeHours }} --pressure-docker-age-hours {{ resourceSafety.diskGcPressureDockerAgeHours }}{% for cache in resourceSafety.diskGcPressureCaches %} --pressure-cache {{ cache }}{% endfor %}
279
+ ExecStart=/usr/bin/python3 -I -B {{ disk_gc_source.stat.path }} --home {{ development_home }} --path / --path {{ development_home }} --idle-days {{ resourceSafety.diskGcIdleDays }} --pressure-mib {{ resourceSafety.diskGcPressureMiB }} --pressure-inode-percent {{ resourceSafety.inodeWarnFreePercent }} --pressure-idle-hours {{ resourceSafety.diskGcPressureIdleHours }} --docker-age-hours {{ resourceSafety.diskGcDockerAgeHours }} --pressure-docker-age-hours {{ resourceSafety.diskGcPressureDockerAgeHours }}{% for cache in resourceSafety.diskGcPressureCaches %} --pressure-cache {{ cache }}{% endfor %}
280
280
 
281
281
  TimeoutStartSec=2h
282
282
  Nice=19
@@ -29,3 +29,16 @@
29
29
  ansible.builtin.systemd_service:
30
30
  daemon_reload: true
31
31
  when: recovery_dropins.changed
32
+ # A skewed clock breaks TLS, tokens, apt signatures and Vault logins with
33
+ # errors that never mention time. Ubuntu ships one time service (chrony on
34
+ # newer releases, systemd-timesyncd before); whichever is installed stays
35
+ # enabled. A container takes its clock from its host. Measured as worker:clock.
36
+ - name: Inspect the time services this worker has
37
+ ansible.builtin.service_facts:
38
+ - name: Keep the clock synchronised with the time service Ubuntu ships
39
+ ansible.builtin.systemd_service:
40
+ name: '{{ item }}'
41
+ enabled: true
42
+ state: started
43
+ loop: "{{ (['chrony.service', 'systemd-timesyncd.service'] | select('in', ansible_facts.services) | list)[:1] }}"
44
+ when: ansible_facts.virtualization_type not in ['docker', 'container', 'podman', 'lxc', 'systemd-nspawn']
@@ -1,4 +1,9 @@
1
- import { workerTools } from './worker-tools';
1
+ import {
2
+ NO_SELECTION,
3
+ parseSelectionArguments,
4
+ workerToolset,
5
+ type ToolSelection,
6
+ } from './worker-tools';
2
7
  /** Read-only toolchain checks; no logins, installations or changes to Docker. */
3
8
  import { spawnSync } from 'node:child_process';
4
9
  import { readFileSync } from 'node:fs';
@@ -15,7 +20,7 @@ import {
15
20
  type Tool = string | { version: string; lazy?: boolean; os?: string[] };
16
21
  type Config = {
17
22
  tools: Record<string, Tool>;
18
- vars?: { worker_tools?: unknown };
23
+ vars?: Record<string, unknown>;
19
24
  };
20
25
  export type Check = {
21
26
  id: string;
@@ -42,6 +47,11 @@ const VERSION_ARGS: Record<string, string[]> = {
42
47
  kubectl: ['version', '--client=true', '--output=json'],
43
48
  helm: ['version', '--short'],
44
49
  };
50
+ /** The version a tool prints, where it differs from its mise pin. */
51
+ const PRINTED: Record<string, (pin: string) => string> = {
52
+ // temurin-25.0.5+8.0.LTS prints as 25.0.5.
53
+ java: (pin) => pin.replace(/^[a-z]+-/, '').replace(/\+.*$/, ''),
54
+ };
45
55
  const run: Runner = (command) => {
46
56
  const result = spawnSync(command[0]!, command.slice(1), {
47
57
  encoding: 'utf8',
@@ -80,7 +90,8 @@ export function toolChecks(
80
90
  if (typeof tool !== 'string' && tool.lazy) continue;
81
91
  // A tool pinned only for other platforms has no artifact to verify here.
82
92
  if (unavailable(id, tool, platform)) continue;
83
- const version = typeof tool === 'string' ? tool : tool.version;
93
+ const pin = typeof tool === 'string' ? tool : tool.version;
94
+ const version = PRINTED[id]?.(pin) ?? pin;
84
95
  const command = COMMANDS[id] ?? id;
85
96
  const result = execute([command, ...(VERSION_ARGS[id] ?? ['--version'])]);
86
97
  const exact = new RegExp(
@@ -195,7 +206,21 @@ export function globalConfigCheck(
195
206
  };
196
207
  }
197
208
 
198
- export function checkMachine(requireDocker = false, worker = false): Check[] {
209
+ /** A worker installs every tool it selects, lazy or not, so each is checked. */
210
+ function installed(tools: Record<string, Tool>): Record<string, Tool> {
211
+ return Object.fromEntries(
212
+ Object.entries(tools).map(([id, tool]) => [
213
+ id,
214
+ typeof tool === 'string' ? tool : { ...tool, lazy: false },
215
+ ])
216
+ );
217
+ }
218
+
219
+ export function checkMachine(
220
+ requireDocker = false,
221
+ worker = false,
222
+ selection: ToolSelection = NO_SELECTION
223
+ ): Check[] {
199
224
  const config = Bun.TOML.parse(
200
225
  readFileSync(join(workspaceRoot, 'mise.toml'), 'utf8')
201
226
  ) as Config;
@@ -203,7 +228,7 @@ export function checkMachine(requireDocker = false, worker = false): Check[] {
203
228
  return [
204
229
  ...toolChecks(
205
230
  worker
206
- ? workerTools(config.tools, config.vars?.worker_tools)
231
+ ? installed(workerToolset(config.tools, config.vars ?? {}, selection))
207
232
  : config.tools
208
233
  ),
209
234
  ...dockerChecks(requireDocker),
@@ -222,7 +247,8 @@ export function checkMachine(requireDocker = false, worker = false): Check[] {
222
247
  if (import.meta.main) {
223
248
  const checks = checkMachine(
224
249
  process.argv.includes('--docker'),
225
- process.argv.includes('--worker')
250
+ process.argv.includes('--worker'),
251
+ parseSelectionArguments(process.argv)
226
252
  );
227
253
  const ok = checks.every((check) => check.status !== 'fail');
228
254
  if (process.argv.includes('--json'))
@@ -41,7 +41,7 @@ remedy() {
41
41
  BOOTSTRAP_LOCK_TOOL_REQUIRED) echo 'Install python3 so the operation lock can be taken, then rerun fleet-sync.';;
42
42
  SOURCE_CACHE_CHANGED|SOURCE_STATUS_FAILED|SOURCE_CHECKOUT_CONFLICT) echo 'The fleet-owned checkout of this revision holds changes fleet-sync did not make. Inspect and remove that revisions directory, then rerun fleet-sync.';;
43
43
  CONFIGURATION_INSTALL_FAILED) echo 'The fleet configuration could not be written under ~/.config/dev-fleet. Check that directory, then rerun fleet-sync.';;
44
- TOOLCHAIN_*) echo 'The pinned toolchain did not converge. Rerun fleet-sync; it resumes where it stopped. If it repeats, run devfleet doctor on the worker.';;
44
+ TOOLCHAIN_*) echo 'The pinned toolchain did not converge. Rerun fleet-sync; it resumes where it stopped. If it repeats, run devfleet fleet-status --node for this worker.';;
45
45
  *) echo 'Inspect the remote operation lock and rerun the approved revision. No success receipt advanced.';;
46
46
  esac
47
47
  }
@@ -97,7 +97,18 @@ else
97
97
  command -v python3 >/dev/null 2>&1 || die BOOTSTRAP_LOCK_TOOL_REQUIRED
98
98
  python3 -c 'import fcntl; fcntl.flock(9, fcntl.LOCK_EX | fcntl.LOCK_NB)' 9>&9 || die FLEET_REMOTE_LOCKED
99
99
  fi
100
- printf '{"version":1,"pid":%s,"revision":"%s","node":"%s"}\n' "$$" "$revision" "$node" >"$root/remote-operation.json"
100
+ # The operation record: a manager that lost its session reads it to follow
101
+ # this operation to its end instead of starting another (see
102
+ # src/remote-operation.ts). The result of the last operation is beside it.
103
+ started=$(date +%s)
104
+ result="$root/remote-operation-result.json"
105
+ [ ! -L "$root/remote-operation.json" ] && [ ! -L "$result" ] || die FLEET_STATE_PATH_UNSAFE
106
+ rm -f -- "$result"
107
+ operation() {
108
+ printf '{"version":1,"pid":%s,"revision":"%s","node":"%s","started":%s,"phase":"%s"%s}\n' "$$" "$revision" "$node" "$started" "$1" "${2:-}" >"$root/remote-operation.json.new" &&
109
+ mv -f -- "$root/remote-operation.json.new" "$root/remote-operation.json"
110
+ }
111
+ operation lock
101
112
  # The fleet configuration is installed under the lock, so two overlapping
102
113
  # runs never write it at once. It holds no secret.
103
114
  phase='configuration-install'
@@ -109,6 +120,7 @@ if [ -n "$configuration" ]; then
109
120
  mv "$config_dir/fleet.json.new" "$config_dir/fleet.json" || die CONFIGURATION_INSTALL_FAILED
110
121
  fi
111
122
  phase=resources
123
+ operation resources
112
124
  # A bootstrap floor protects source transfer and minimal prerequisites. The
113
125
  # Workstation baseline subsequently enforces its stricter declared capacity.
114
126
  free_kib=$(df -Pk "$root" | awk 'END {print $4}')
@@ -200,6 +212,7 @@ bundle=''
200
212
  clean=$(git -C "$deployment" status --porcelain) || die SOURCE_STATUS_FAILED
201
213
  [ -z "$clean" ] || die SOURCE_CHECKOUT_CONFLICT
202
214
  phase=toolchain
215
+ operation toolchain
203
216
  cd "$deployment"
204
217
  export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:/usr/local/bin:/usr/bin:/bin"
205
218
  status_file="$root/toolchain-status.json"
@@ -211,6 +224,11 @@ ready_file="$root/toolchain-ready"
211
224
  # are still checked (and reinstalled on drift) by node convergence.
212
225
  toolchain_key=$(git rev-parse HEAD:mise.toml HEAD:mise.lock HEAD:.mise HEAD:bun.lock HEAD:package.json HEAD:bootstrap 2>/dev/null | tr '\n' ' ')
213
226
  [ -n "$toolchain_key" ] || toolchain_key=$revision
227
+ # The tool profiles of the fleet configuration choose what is installed, so a
228
+ # changed configuration runs the setup again.
229
+ if [ -f "$HOME/.config/dev-fleet/fleet.json" ]; then
230
+ toolchain_key="$toolchain_key$(cksum <"$HOME/.config/dev-fleet/fleet.json" | cut -d' ' -f1)"
231
+ fi
214
232
  if [ "$(cat "$ready_file" 2>/dev/null)" = "$toolchain_key" ] && [ -n "$(find "$ready_file" -mmin -1440 2>/dev/null)" ]; then
215
233
  toolchain_warm=1
216
234
  else
@@ -219,7 +237,7 @@ else
219
237
  fi
220
238
  if command -v timeout >/dev/null 2>&1; then bounded='timeout 1800'; else bounded=''; fi
221
239
  # shellcheck disable=SC2086
222
- if [ -z "$toolchain_warm" ] && ! $bounded bash bootstrap/bootstrap.sh --worker --non-interactive --status-file "$status_file" >/dev/null 2>&1; then
240
+ if [ -z "$toolchain_warm" ] && ! $bounded bash bootstrap/bootstrap.sh --worker --node "$node" --non-interactive --status-file "$status_file" >/dev/null 2>&1; then
223
241
  tool_phase=$(sed -n 's/.*"phase":"\([a-z0-9-]*\)".*/\1/p' "$status_file" 2>/dev/null) || tool_phase=''
224
242
  case "$tool_phase" in
225
243
  managed-config) die TOOLCHAIN_CONFIG_CONFLICT;;
@@ -254,5 +272,14 @@ for old in $(ls -1t "$root/revisions"); do
254
272
  rm -rf -- "${root:?}/revisions/$old" || true
255
273
  done
256
274
  phase=configuration
275
+ operation configuration
257
276
  trap - EXIT INT TERM
258
- exec mise exec --locked bun -- bun src/node-converge.ts "$node" "$revision"
277
+ # Node convergence writes its evidence to the result file, not to the SSH
278
+ # session: the session may be gone by the time it ends. The evidence is then
279
+ # printed for the manager that is still connected, under the same lock.
280
+ code=0
281
+ mise exec --locked bun -- bun src/node-converge.ts "$node" "$revision" >"$result.partial" 2>"$root/remote-operation.log" || code=$?
282
+ mv -f -- "$result.partial" "$result"
283
+ operation finished ",\"finished\":$(date +%s),\"exit\":$code"
284
+ cat -- "$result" || true
285
+ exit "$code"
@@ -1,4 +1,9 @@
1
- import { workerTools } from './worker-tools';
1
+ import {
2
+ installedSelection,
3
+ NO_SELECTION,
4
+ workerToolset,
5
+ type ToolSelection,
6
+ } from './worker-tools';
2
7
  import { readFileSync } from 'node:fs';
3
8
  import { join } from 'node:path';
4
9
  /** Used by both OS bootstraps, dev-fleet tools sync and dev-fleet update. No workspace dependencies. */
@@ -26,7 +31,8 @@ function run(args: string[]): void {
26
31
 
27
32
  export async function syncTools(
28
33
  observe: (phase: SetupPhase) => void = () => {},
29
- worker = false
34
+ worker = false,
35
+ selection: ToolSelection = NO_SELECTION
30
36
  ): Promise<void> {
31
37
  observe('managed-config');
32
38
  const dir = globalConfigDir();
@@ -34,9 +40,9 @@ export async function syncTools(
34
40
  observe('mise');
35
41
  const config = Bun.TOML.parse(
36
42
  readFileSync(join(workspaceRoot, 'mise.toml'), 'utf8')
37
- ) as { tools: Record<string, unknown>; vars: { worker_tools: unknown } };
43
+ ) as { tools: Record<string, unknown>; vars: Record<string, unknown> };
38
44
  const selected = worker
39
- ? Object.keys(workerTools(config.tools, config.vars.worker_tools))
45
+ ? Object.keys(workerToolset(config.tools, config.vars, selection))
40
46
  : [];
41
47
  run(['mise', 'install', '--locked', ...selected]);
42
48
  observe('aws');
@@ -54,11 +60,20 @@ if (import.meta.main) {
54
60
  const flag = process.argv.indexOf('--status-file');
55
61
  const report = setupStatus(flag < 0 ? undefined : process.argv[flag + 1]);
56
62
  let phase: SetupPhase = 'managed-config';
63
+ const worker = process.argv.includes('--worker');
64
+ const node = process.argv.indexOf('--node');
57
65
  try {
58
- await syncTools((current) => {
59
- phase = current;
60
- report(phase, 'running');
61
- }, process.argv.includes('--worker'));
66
+ await syncTools(
67
+ (current) => {
68
+ phase = current;
69
+ report(phase, 'running');
70
+ },
71
+ worker,
72
+ // The tool profiles of the fleet configuration fleet-sync installed.
73
+ worker && node >= 0
74
+ ? installedSelection(process.argv[node + 1])
75
+ : NO_SELECTION
76
+ );
62
77
  if (!process.argv.includes('--tools-only')) {
63
78
  phase = 'dependencies';
64
79
  report(phase, 'running');
@@ -1,3 +1,7 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
1
5
  /** A worker installs only this declared subset; every version still comes from mise.lock. */
2
6
  export function workerTools<T>(
3
7
  tools: Record<string, T>,
@@ -22,3 +26,117 @@ export function workerTools<T>(
22
26
  selected.map((key: string) => [key, tools[key]!])
23
27
  ) as Record<string, T>;
24
28
  }
29
+
30
+ /** Named tool sets a fleet adds to its workers on top of the base set
31
+ * (`worker_tools`). Each is `worker_profile_<name>` under [vars] in
32
+ * mise.toml; every version still comes from mise.lock.
33
+ */
34
+ export const TOOL_PROFILES = ['web', 'go', 'rust', 'jvm'] as const;
35
+ export type ToolProfile = (typeof TOOL_PROFILES)[number];
36
+
37
+ /** What a fleet configuration adds to one worker's base tools: profiles and
38
+ * single tools, each already pinned in mise.toml.
39
+ */
40
+ export interface ToolSelection {
41
+ profiles: string[];
42
+ tools: string[];
43
+ }
44
+ export const NO_SELECTION: ToolSelection = { profiles: [], tools: [] };
45
+
46
+ const list = (value: unknown): string[] =>
47
+ Array.isArray(value)
48
+ ? value.filter((item): item is string => typeof item === 'string')
49
+ : [];
50
+ const record = (value: unknown): value is Record<string, unknown> =>
51
+ typeof value === 'object' && value !== null && !Array.isArray(value);
52
+
53
+ /** The selection a fleet configuration makes for one worker: the fleet-wide
54
+ * `worker.toolProfiles` and `worker.tools`, plus that worker's own
55
+ * `toolProfiles`. Read from the plain document, so the bootstrap needs no
56
+ * fleet code.
57
+ */
58
+ export function workerSelection(
59
+ definition: unknown,
60
+ node?: string
61
+ ): ToolSelection {
62
+ if (!record(definition)) return NO_SELECTION;
63
+ const policy = record(definition['worker']) ? definition['worker'] : {};
64
+ const workers: unknown[] = Array.isArray(definition['workers'])
65
+ ? definition['workers']
66
+ : [];
67
+ const own = workers
68
+ .filter(record)
69
+ .filter((entry) => node !== undefined && entry['id'] === node)
70
+ .flatMap((entry) => list(entry['toolProfiles']));
71
+ return {
72
+ profiles: [...new Set([...list(policy['toolProfiles']), ...own])],
73
+ tools: [...new Set(list(policy['tools']))],
74
+ };
75
+ }
76
+
77
+ /** The selection of the fleet configuration fleet-sync installed on this
78
+ * machine; none when there is no configuration yet.
79
+ */
80
+ export function installedSelection(
81
+ node: string | undefined,
82
+ home: string = homedir()
83
+ ): ToolSelection {
84
+ const path = join(home, '.config/dev-fleet/fleet.json');
85
+ if (!existsSync(path)) return NO_SELECTION;
86
+ try {
87
+ return workerSelection(JSON.parse(readFileSync(path, 'utf8')), node);
88
+ } catch {
89
+ return NO_SELECTION;
90
+ }
91
+ }
92
+
93
+ /** The tools of one worker: the base set, then each selected profile's, then
94
+ * the single tools, in that order and each once. A profile mise.toml does not
95
+ * declare, or a tool it does not pin, is refused.
96
+ */
97
+ export function workerToolset<T>(
98
+ tools: Record<string, T>,
99
+ vars: Record<string, unknown>,
100
+ selection: ToolSelection = NO_SELECTION
101
+ ): Record<string, T> {
102
+ const names = (value: unknown) =>
103
+ typeof value === 'string' ? value.split(',').filter(Boolean) : [];
104
+ const base = names(vars['worker_tools']);
105
+ const added = selection.profiles.flatMap((profile) => {
106
+ const declared = vars[`worker_profile_${profile}`];
107
+ if (typeof declared !== 'string' || !declared)
108
+ throw new Error(
109
+ `Unknown worker tool profile "${profile}". Choose from ${TOOL_PROFILES.join(', ')}.`
110
+ );
111
+ return names(declared);
112
+ });
113
+ for (const tool of selection.tools)
114
+ if (!Object.hasOwn(tools, tool))
115
+ throw new Error(
116
+ `The worker tool "${tool}" has no pin in dev-fleet's mise.toml. Ask for it in an issue.`
117
+ );
118
+ return workerTools(tools, [
119
+ ...new Set([...base, ...added, ...selection.tools]),
120
+ ]);
121
+ }
122
+
123
+ /** `--profiles web,go --tools fd` for the doctor and the setup. */
124
+ export function selectionArguments(selection: ToolSelection): string[] {
125
+ return [
126
+ ...(selection.profiles.length
127
+ ? ['--profiles', selection.profiles.join(',')]
128
+ : []),
129
+ ...(selection.tools.length ? ['--tools', selection.tools.join(',')] : []),
130
+ ];
131
+ }
132
+
133
+ /** The selection named by `--profiles` and `--tools`. */
134
+ export function parseSelectionArguments(argv: string[]): ToolSelection {
135
+ const value = (flag: string) => {
136
+ const index = argv.indexOf(flag);
137
+ return index < 0
138
+ ? []
139
+ : (argv[index + 1] ?? '').split(',').filter((item) => item.length > 0);
140
+ };
141
+ return { profiles: value('--profiles'), tools: value('--tools') };
142
+ }
@@ -150,7 +150,7 @@ devfleet cliproxy inference messages EXACT_CLAUDE_MODEL_ID
150
150
 
151
151
  **Account routing.** Credential files are the only list of accounts. `ops declared` (run by every fleet-sync) gives each Claude, Codex and Antigravity credential the model prefix `<type>-<email>` through the proxy's management API (`scripts/accounts.py`; a credential file is never edited directly). A request for `<type>-<email>/<model>` is served only by that account, and `/v1/models` lists those ids, which is how every fleet node discovers the accounts to offer in T3. Nothing in Git names an account.
152
152
 
153
- Unprefixed routing is **fill-first** (`routing.strategy: fill-first`): one account serves until its quota is exhausted and it cools down, then the next account, with session affinity keeping a session on its account while available and bounded retries across eligible accounts for the requested model. `cliproxy apply` requires this policy and the host monitor reports drift; see [Multi-account routing](docs/model-health.md#multi-account-routing-fill-first) for applying it to a running proxy. Automatic preview-model switching is disabled. No model aliases are configured: clients use the upstream model ids exactly as `/v1/models` lists them. [`models.json`](models.json) declares the model ids that are health-checked (`claude-opus-5-5`, `claude-sonnet-5-5`, `gpt-6-astra`, `gpt-6.1-sol`), and the checks fail loudly when one is not served. Usage statistics are enabled but are not a durable billing ledger. Available quota details depend on the provider.
153
+ Unprefixed routing is **fill-first** (`routing.strategy: fill-first`): one account serves until its quota is exhausted and it cools down, then the next account, with session affinity keeping a session on its account while available and bounded retries across eligible accounts for the requested model. `cliproxy apply` requires this policy and the host monitor reports drift; see [Multi-account routing](docs/model-health.md#multi-account-routing-fill-first) for applying it to a running proxy. Automatic preview-model switching is disabled. The only model aliases are the ones `config.template.yaml` declares under `oauth.model-alias` (`ops` applies them and waits until `/v1/models` serves them); every other model id is the upstream id exactly as `/v1/models` lists it. [`models.json`](models.json) declares the model ids that are health-checked (`claude-opus-5-5`, `claude-sonnet-5-5`, `gpt-6-astra`, `gpt-6.1-sol`), and the checks fail loudly when one is not served. Usage statistics are enabled but are not a durable billing ledger. Available quota details depend on the provider.
154
154
 
155
155
  ## Fleet clients: macOS, Ubuntu and Windows/WSL
156
156
 
@@ -3,6 +3,10 @@ services:
3
3
  proxy:
4
4
  image: ${CLI_PROXY_IMAGE:?image must be pinned}
5
5
  restart: unless-stopped
6
+ # Docker restarts a proxy that exits; the container watchdog on the
7
+ # manager restarts one that stays unhealthy.
8
+ labels:
9
+ dev.devfleet.watchdog: restart
6
10
  user: '${CLIPROXY_UID:-1000}:${CLIPROXY_GID:-1000}'
7
11
  working_dir: /state
8
12
  command: ['/CLIProxyAPI/CLIProxyAPI', '-config', '/state/config.yaml']
@@ -68,7 +68,7 @@ def validate(root):
68
68
  raise ValueError("Provider cache directory missing")
69
69
  for path in (state / "runtime/auths").iterdir():
70
70
  if path.name == "kv.db" and path.is_file() and path.stat().st_size > 0:
71
- continue # Opaque upstream bbolt state, captured while the process is stopped.
71
+ continue # Opaque upstream bbolt state, copied while the proxy runs (see capture).
72
72
  # .json: provider credential; .cds: upstream per-account cooldown
73
73
  # records (JSON object, no credential), kept so limits survive moves.
74
74
  if path.suffix not in (".json", ".cds") or not path.is_file():
@@ -0,0 +1,90 @@
1
+ # Authentication and accounts
2
+
3
+ Where each credential dev-fleet uses lives, who can read it, how it reaches
4
+ each node, how it rotates and what happens when it fails. The three tables
5
+ are generated from the code; `features/rules/authentication.feature` fails
6
+ when one is out of date (`DEVFLEET_UPDATE_DOCS=1 bun test features/rules`
7
+ rewrites them).
8
+
9
+ ## Where credentials live
10
+
11
+ | Credential | Lives in | Read by |
12
+ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
13
+ | The fleet's secrets (table below) | The provider `secrets` names in `fleet.json` | The node that needs it, through `src/secrets.ts` |
14
+ | The bucket's two keys | The environment of the `devfleet` command; `~/.config/dev-fleet/bucket.json` (0600) on the manager after `fleet-state --init` | The manager only |
15
+ | A node's Vault token | That node's own Vault session (`~/.vault-token`) | That node only; never copied |
16
+ | A node's GitHub login | `gh`'s own store on that node | `gh`, and git through its credential helper |
17
+ | Provider accounts (Claude, Codex …) | The CLI proxy's store on the manager | The proxy; nodes hold only the proxy client key |
18
+ | Tailscale and T3 pairing | Each machine, signed in by a person | That machine |
19
+
20
+ ## How they reach each node
21
+
22
+ - **Vault.** A person logs each machine in once (`fleet-sync` asks on the
23
+ manager and passes the password to the worker over SSH on standard input).
24
+ From then on the worker's `personal-worker-auth` timer renews its own token
25
+ (`bootstrap/worker-auth.ts`). Nothing renders provider configuration from
26
+ Vault on a worker: agent profiles come from `fleet-sync`.
27
+ - **GitHub.** The same job restores the declared account (`worker.githubLogin`)
28
+ with the GitHub service token it reads from its own Vault session
29
+ (`bootstrap/worker-github.ts`, on standard input, never argv). `fleet-sync`
30
+ makes `gh` git's credential helper and writes the declared identity
31
+ (`git` in `fleet.json`, `ansible/github/git.yml`) on every node.
32
+ - **The proxy client key** is read by each node from its own secrets provider
33
+ whenever a launcher starts an agent.
34
+ - **The monitoring hub's backup storage keys** are read on the manager by
35
+ `fleet-sync` and handed to the hub's playbook in a private variables file.
36
+
37
+ ## Rotation and failure
38
+
39
+ Rotate a secret where the provider keeps it; the next read uses it, nothing
40
+ caches it. A Vault token that is not renewed expires: `worker:vault-renewal`
41
+ fails and the fix is one login of that machine. A GitHub token that stops
42
+ working fails `auth:github` and `worker:git-push-auth`; store the new one in
43
+ Vault and the worker restores itself within ten minutes. During a proxy
44
+ outage agents on workers wait: no worker is configured for native clients.
45
+ Every failure code is in [the runbooks](runbooks.md).
46
+
47
+ ## The fleet's secrets
48
+
49
+ <!-- generated: secrets -->
50
+
51
+ | Secret | Required | `env` provider variable | `file` provider file |
52
+ | ------------------------------ | ---------------------------- | ---------------------------------------------- | ---------------------------------------------------------- |
53
+ | `proxy-client-key` | yes | `DEVFLEET_SECRET_PROXY_CLIENT_KEY` | `~/.config/dev-fleet/secrets/proxy-client-key` |
54
+ | `monitoring-account-password` | yes | `DEVFLEET_SECRET_MONITORING_ACCOUNT_PASSWORD` | `~/.config/dev-fleet/secrets/monitoring-account-password` |
55
+ | `monitoring-tunnel-token` | yes | `DEVFLEET_SECRET_MONITORING_TUNNEL_TOKEN` | `~/.config/dev-fleet/secrets/monitoring-tunnel-token` |
56
+ | `proxy-tunnel-token` | yes | `DEVFLEET_SECRET_PROXY_TUNNEL_TOKEN` | `~/.config/dev-fleet/secrets/proxy-tunnel-token` |
57
+ | `monitoring-backup-access-key` | when its feature is declared | `DEVFLEET_SECRET_MONITORING_BACKUP_ACCESS_KEY` | `~/.config/dev-fleet/secrets/monitoring-backup-access-key` |
58
+ | `monitoring-backup-secret-key` | when its feature is declared | `DEVFLEET_SECRET_MONITORING_BACKUP_SECRET_KEY` | `~/.config/dev-fleet/secrets/monitoring-backup-secret-key` |
59
+ | `notify-webhook-url` | when its feature is declared | `DEVFLEET_SECRET_NOTIFY_WEBHOOK_URL` | `~/.config/dev-fleet/secrets/notify-webhook-url` |
60
+ | `notify-ntfy-url` | when its feature is declared | `DEVFLEET_SECRET_NOTIFY_NTFY_URL` | `~/.config/dev-fleet/secrets/notify-ntfy-url` |
61
+
62
+ <!-- end: secrets -->
63
+
64
+ ## Checks of credentials
65
+
66
+ <!-- generated: checks -->
67
+
68
+ | Check | Measured in |
69
+ | ----------------------------- | ------------------------------------------ |
70
+ | `auth:vault` | `src/cli/fleet-sync-vault.ts` |
71
+ | `worker:git-identity` | `src/git-identity.ts` |
72
+ | `worker:git-push-auth` | `src/definition.ts`, `src/git-identity.ts` |
73
+ | `worker:github-principal` | `src/worker-access-checks.ts` |
74
+ | `worker:proxy-authentication` | `src/worker-access-checks.ts` |
75
+ | `worker:vault-renewal` | `src/worker-access-checks.ts` |
76
+
77
+ <!-- end: checks -->
78
+
79
+ ## Timers
80
+
81
+ <!-- generated: timers -->
82
+
83
+ | Timer | Declared in | Schedule |
84
+ | -------------------------------- | -------------------------------------- | ------------------------------------- |
85
+ | `personal-fleet-disk-gc.timer` | `ansible/ubuntu/tasks/resources.yml` | OnBootSec=10min, OnCalendar=hourly |
86
+ | `personal-worker-auth.timer` | `ansible/ubuntu/tasks/credentials.yml` | OnBootSec=2min, OnUnitActiveSec=10min |
87
+ | `personal-worker-recovery.timer` | `ansible/ubuntu/tasks/recovery.yml` | OnBootSec=1min, OnUnitActiveSec=1min |
88
+ | `personal-worker-recovery.timer` | `ansible/ubuntu/tasks/recovery.yml` | OnBootSec=5min, OnUnitActiveSec=15min |
89
+
90
+ <!-- end: timers -->
package/docs/declared.md CHANGED
@@ -31,6 +31,8 @@ may only shrink.
31
31
  | Ansible on a worker | `ansible-core` in `ansible/ubuntu/policy.json` | Exact version | `ansible/ubuntu/tasks/packages.yml` | `package:ansible-core` |
32
32
  | The CLI proxy | `cliproxy/compose.yaml`, `image.lock.json`, `requirements.lock`, `launchd/`, `config.template.yaml` | Image digests; hashed Python dependencies | `devfleet cliproxy install`, and `fleet-sync` after it | The proxy's audit |
33
33
  | Agent profiles and T3 providers | `agents/registry.json`, `agents/t3.json` | The commit | `fleet-sync` | Profile receipts; the plan after every apply |
34
+ | Worker time service | `ansible/ubuntu/tasks/services.yml`, offsets in `clock` in `ansible/ubuntu/policy.json` | The commit | `fleet-sync` | `worker:clock` |
35
+ | The manager's container watchdog | `ansible/monitoring/container-watchdog.py`, `containerWatchdog` in `ansible/macos/policy.json` | The commit | `fleet-sync` (`hub-darwin.yml`) | `manager:container-watchdog` |
34
36
  | Docker Desktop on the manager | `docker_desktop_*` under `[vars]` in `mise.toml` | Version and build | You | `manager:docker` (warns) |
35
37
  | CI | `.github/workflows/` | Each action at a commit; tools from `mise.lock` | GitHub | `features/rules/` |
36
38