@crvouga/dev-fleet 0.3.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 (97) hide show
  1. package/README.md +25 -17
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/policy.json +12 -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/platforms.json +111 -0
  9. package/ansible/ubuntu/authorize-sudo.sh +7 -16
  10. package/ansible/ubuntu/baseline.yml +6 -28
  11. package/ansible/ubuntu/disk-gc.py +211 -7
  12. package/ansible/ubuntu/disk-guard.py +24 -5
  13. package/ansible/ubuntu/policy.json +68 -4
  14. package/ansible/ubuntu/t3-desktop.yml +2 -2
  15. package/ansible/ubuntu/tailscale.yml +20 -9
  16. package/ansible/ubuntu/tasks/browser.yml +56 -0
  17. package/ansible/ubuntu/tasks/docker.yml +52 -0
  18. package/ansible/ubuntu/tasks/dpkg.yml +46 -0
  19. package/ansible/ubuntu/tasks/install.yml +38 -0
  20. package/ansible/ubuntu/tasks/packages.yml +4 -55
  21. package/ansible/ubuntu/tasks/release.yml +25 -0
  22. package/ansible/ubuntu/tasks/resources.yml +7 -6
  23. package/ansible/ubuntu/tasks/services.yml +13 -0
  24. package/ansible/ubuntu/tasks/snapshot.yml +50 -0
  25. package/ansible/ubuntu/tasks/unattended-upgrades.yml +24 -0
  26. package/bootstrap/doctor.ts +32 -6
  27. package/bootstrap/fleet-remote-bootstrap.sh +49 -45
  28. package/bootstrap/setup.ts +23 -8
  29. package/bootstrap/worker-tools.ts +118 -0
  30. package/cliproxy/README.md +1 -1
  31. package/cliproxy/compose.yaml +4 -0
  32. package/cliproxy/scripts/checkpoint.py +1 -1
  33. package/docs/authentication.md +90 -0
  34. package/docs/consumer-setup.md +32 -30
  35. package/docs/declared.md +6 -5
  36. package/docs/machine-setup.md +22 -12
  37. package/docs/operations.md +87 -31
  38. package/docs/runbooks.md +377 -0
  39. package/docs/state.md +13 -7
  40. package/docs/support-matrix.md +36 -0
  41. package/docs/ubuntu-sync.md +16 -13
  42. package/docs/worker-contract.md +28 -1
  43. package/examples/fleet.json +19 -1
  44. package/llms.txt +2 -0
  45. package/mise.toml +7 -0
  46. package/package.json +2 -2
  47. package/schema/check-ids.json +526 -0
  48. package/schema/fleet.schema.json +185 -4
  49. package/schema/output/check.schema.json +23 -0
  50. package/schema/output/error.schema.json +14 -0
  51. package/schema/output/fleet-report.schema.json +79 -0
  52. package/schema/output/fleet-sync.schema.json +112 -0
  53. package/schema/output/report.schema.json +38 -0
  54. package/src/auth-checks.ts +3 -0
  55. package/src/cli/agent-guide.ts +5 -3
  56. package/src/cli/fleet-report.ts +126 -0
  57. package/src/cli/fleet-sync-manager.ts +11 -0
  58. package/src/cli/fleet-sync-plan.ts +200 -0
  59. package/src/cli/fleet-sync-remote.ts +4 -2
  60. package/src/cli/fleet-sync-schedule.ts +142 -35
  61. package/src/cli/fleet-sync.ts +90 -8
  62. package/src/cli/fleet-watch.ts +198 -0
  63. package/src/cli/fleet.ts +4 -0
  64. package/src/cli/register-fleet.ts +51 -0
  65. package/src/cli/registry.ts +13 -0
  66. package/src/cli/schedule-logs.ts +36 -0
  67. package/src/clock.ts +63 -0
  68. package/src/definition.ts +160 -12
  69. package/src/git-identity.ts +160 -0
  70. package/src/machine/platform.ts +48 -28
  71. package/src/machine/support-matrix.ts +76 -0
  72. package/src/machine-health.ts +51 -4
  73. package/src/manager-health.ts +205 -0
  74. package/src/manager-sync.ts +27 -9
  75. package/src/monitoring/hub-backup.ts +63 -17
  76. package/src/monitoring/manager-hub.ts +44 -2
  77. package/src/network-definition.ts +1 -1
  78. package/src/node-converge-linux.ts +16 -5
  79. package/src/node-converge.ts +1 -1
  80. package/src/notify.ts +239 -0
  81. package/src/platform/debian-version.ts +62 -0
  82. package/src/platform/ubuntu-audit.ts +11 -4
  83. package/src/platform/ubuntu-release.ts +41 -0
  84. package/src/remote-bootstrap.ts +89 -25
  85. package/src/remote-operation.ts +213 -0
  86. package/src/schedule-check.ts +40 -0
  87. package/src/secrets.ts +26 -8
  88. package/src/ssh.ts +10 -2
  89. package/src/state/history.ts +172 -0
  90. package/src/state/store.ts +11 -4
  91. package/src/tailscale.ts +51 -0
  92. package/src/toolchain-checks.ts +8 -3
  93. package/src/worker-capability-checks.ts +167 -0
  94. package/src/worker-capacity.ts +19 -7
  95. package/src/worker-host-checks.ts +68 -10
  96. package/src/worker-policy.ts +33 -0
  97. package/src/worker-readiness.ts +10 -1
@@ -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 }}'
@@ -1,56 +1,5 @@
1
1
  ---
2
- - name: Create owned APT configuration directory
3
- ansible.builtin.file:
4
- path: /etc/personal-fleet
5
- state: directory
6
- owner: root
7
- group: root
8
- mode: '0755'
9
- - name: Declare snapshot sources used only for managed package transactions
10
- ansible.builtin.copy:
11
- dest: /etc/personal-fleet/ubuntu.sources
12
- owner: root
13
- group: root
14
- mode: '0644'
15
- backup: true
16
- content: |
17
- Types: deb
18
- URIs: https://snapshot.ubuntu.com/ubuntu/{{ snapshot }}/
19
- Suites: resolute resolute-updates resolute-security
20
- Components: main universe restricted multiverse
21
- Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
22
- register: snapshot_sources
23
- - name: Isolate snapshot selection from unrelated APT operations
24
- ansible.builtin.copy:
25
- dest: /etc/personal-fleet/apt.conf
26
- owner: root
27
- group: root
28
- mode: '0644'
29
- content: |
30
- Dir::Etc::sourcelist "/etc/personal-fleet/ubuntu.sources";
31
- Dir::Etc::sourceparts "-";
32
- DPkg::Lock::Timeout "{{ aptLockTimeoutSeconds }}";
33
- - name: Inspect immutable snapshot package index
34
- ansible.builtin.stat:
35
- path: '/var/lib/apt/lists/snapshot.ubuntu.com_ubuntu_{{ snapshot }}_dists_resolute_InRelease'
36
- register: snapshot_index
37
- - name: Fetch signed snapshot indexes after source change or interrupted setup
38
- ansible.builtin.apt:
39
- update_cache: true
40
- cache_valid_time: 0
41
- lock_timeout: '{{ aptLockTimeoutSeconds }}'
42
- environment:
43
- APT_CONFIG: /etc/personal-fleet/apt.conf
44
- when: snapshot_sources.changed or not snapshot_index.stat.exists
45
- - name: Install exact managed packages without downgrading or restarting services
46
- ansible.builtin.apt:
47
- name: '{{ item.key }}={{ item.value }}'
48
- state: present
49
- lock_timeout: '{{ aptLockTimeoutSeconds }}'
50
- allow_downgrade: false
51
- allow_unauthenticated: false
52
- fail_on_autoremove: true
53
- policy_rc_d: 101
54
- environment:
55
- APT_CONFIG: /etc/personal-fleet/apt.conf
56
- loop: '{{ packages | dict2items }}'
2
+ - ansible.builtin.include_tasks: install.yml
3
+ vars:
4
+ install_pins: '{{ packages }}'
5
+ - ansible.builtin.import_tasks: unattended-upgrades.yml
@@ -0,0 +1,25 @@
1
+ ---
2
+ # The accepted releases are named once, in policy.json. This selects the
3
+ # entry of the running release and refuses everything else.
4
+ - name: Select the accepted release this machine runs
5
+ ansible.builtin.set_fact:
6
+ ubuntu_release: "{{ releases | selectattr('release', 'equalto', ansible_facts.distribution_version) | first | default({}) }}"
7
+ - name: Require an accepted release, its codename and one of its architectures
8
+ ansible.builtin.assert:
9
+ that:
10
+ - ansible_facts.distribution == 'Ubuntu'
11
+ - ubuntu_release.release is defined
12
+ - ansible_facts.distribution_release == ubuntu_release.codename
13
+ - "(ansible_facts.architecture | replace('x86_64', 'amd64') | replace('aarch64', 'arm64')) in ubuntu_release.architectures"
14
+ fail_msg: UBUNTU_BASELINE_MISMATCH; workers run a release docs/support-matrix.md lists. No distribution upgrade is permitted.
15
+ - name: Pin the packages of this release, its own pins over the common ones
16
+ ansible.builtin.set_fact:
17
+ packages: '{{ packages | combine(ubuntu_release.packages | default({})) }}'
18
+ - name: Read the kernel release to tell WSL2 apart from a machine of its own
19
+ ansible.builtin.slurp:
20
+ src: /proc/sys/kernel/osrelease
21
+ register: kernel_release
22
+ - name: Refuse WSL2 by name
23
+ ansible.builtin.assert:
24
+ that: not (kernel_release.content | b64decode | lower is search('microsoft|wsl'))
25
+ fail_msg: WORKER_ENVIRONMENT_UNSUPPORTED; WSL2 is not a worker. Run the worker on a physical or virtual machine of its own (docs/support-matrix.md).
@@ -49,10 +49,11 @@
49
49
  line: /swap.fleet none swap sw 0 0
50
50
  when: active_swap.stdout | trim == ''
51
51
 
52
- - name: Install the systemd userspace OOM killer
53
- ansible.builtin.apt:
54
- name: systemd-oomd
55
- state: present
52
+ - name: Install the systemd userspace OOM killer at its pin
53
+ ansible.builtin.include_tasks: install.yml
54
+ vars:
55
+ install_pins:
56
+ systemd-oomd: "{{ packages['systemd-oomd'] }}"
56
57
  - name: Create memory and disk policy directories
57
58
  ansible.builtin.file:
58
59
  path: '{{ item }}'
@@ -224,7 +225,7 @@
224
225
  StartLimitIntervalSec=0
225
226
  [Service]
226
227
  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 }}
228
+ 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
229
  Restart=always
229
230
  RestartSec=5
230
231
  OOMScoreAdjust=-1000
@@ -276,7 +277,7 @@
276
277
  After=docker.service
277
278
  [Service]
278
279
  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 %}
280
+ 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 %}{% for root in resourceSafety.diskGcWorktreeRoots %} --worktree-root {{ root }}{% endfor %} --worktree-idle-days {{ resourceSafety.diskGcWorktreeIdleDays }}{% for cache, mib in resourceSafety.diskGcCacheCapsMiB.items() %} --cache-cap {{ cache }}={{ mib }}{% endfor %} --keep-newest /var/cache/personal-fleet={{ resourceSafety.diskGcPackagesKept }} --mise {{ development_home }}/.local/bin/mise --user {{ development_user }}
280
281
 
281
282
  TimeoutStartSec=2h
282
283
  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']
@@ -0,0 +1,50 @@
1
+ ---
2
+ # The snapshot every managed package comes from, separate from the system's
3
+ # own sources. Included before any pinned package is installed; a second
4
+ # inclusion in one run changes nothing.
5
+ - name: Create owned APT configuration directory
6
+ ansible.builtin.file:
7
+ path: /etc/personal-fleet
8
+ state: directory
9
+ owner: root
10
+ group: root
11
+ mode: '0755'
12
+ - name: Declare snapshot sources used only for managed package transactions
13
+ ansible.builtin.copy:
14
+ dest: /etc/personal-fleet/ubuntu.sources
15
+ owner: root
16
+ group: root
17
+ mode: '0644'
18
+ backup: true
19
+ content: |
20
+ Types: deb
21
+ URIs: https://snapshot.ubuntu.com/ubuntu/{{ snapshot }}/
22
+ Suites: {{ ubuntu_release.codename }} {{ ubuntu_release.codename }}-updates {{ ubuntu_release.codename }}-security
23
+ Components: main universe restricted multiverse
24
+ Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
25
+ register: snapshot_sources
26
+ # Fetching the snapshot indexes must leave the indexes of the system's own
27
+ # sources in place: the system's APT and unattended upgrades still use them.
28
+ - name: Isolate snapshot selection from unrelated APT operations
29
+ ansible.builtin.copy:
30
+ dest: /etc/personal-fleet/apt.conf
31
+ owner: root
32
+ group: root
33
+ mode: '0644'
34
+ content: |
35
+ Dir::Etc::sourcelist "/etc/personal-fleet/ubuntu.sources";
36
+ Dir::Etc::sourceparts "-";
37
+ APT::Get::List-Cleanup "false";
38
+ DPkg::Lock::Timeout "{{ aptLockTimeoutSeconds }}";
39
+ - name: Inspect immutable snapshot package index
40
+ ansible.builtin.stat:
41
+ path: '/var/lib/apt/lists/snapshot.ubuntu.com_ubuntu_{{ snapshot }}_dists_{{ ubuntu_release.codename }}_InRelease'
42
+ register: snapshot_index
43
+ - name: Fetch signed snapshot indexes after source change or interrupted setup
44
+ ansible.builtin.apt:
45
+ update_cache: true
46
+ cache_valid_time: 0
47
+ lock_timeout: '{{ aptLockTimeoutSeconds }}'
48
+ environment:
49
+ APT_CONFIG: /etc/personal-fleet/apt.conf
50
+ when: snapshot_sources.changed or not snapshot_index.stat.exists
@@ -0,0 +1,24 @@
1
+ ---
2
+ # Ubuntu installs security updates by itself, and they keep flowing: a pin is
3
+ # a minimum, never a hold. What runs unattended is declared here so it is
4
+ # known: security updates only, never a reboot, never a removal. The fleet's
5
+ # own file sorts after the package's 50unattended-upgrades and replaces its
6
+ # origins.
7
+ - name: Declare which updates Ubuntu installs unattended
8
+ ansible.builtin.copy:
9
+ dest: /etc/apt/apt.conf.d/52personal-fleet-unattended-upgrades
10
+ owner: root
11
+ group: root
12
+ mode: '0644'
13
+ content: |
14
+ // Managed by personal Fleet: unattended upgrades
15
+ APT::Periodic::Update-Package-Lists "1";
16
+ APT::Periodic::Unattended-Upgrade "1";
17
+ #clear Unattended-Upgrade::Allowed-Origins;
18
+ #clear Unattended-Upgrade::Origins-Pattern;
19
+ Unattended-Upgrade::Allowed-Origins {
20
+ "${distro_id}:${distro_codename}-security";
21
+ };
22
+ Unattended-Upgrade::Automatic-Reboot "false";
23
+ Unattended-Upgrade::Remove-Unused-Dependencies "false";
24
+ Unattended-Upgrade::Remove-New-Unused-Dependencies "false";
@@ -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'))
@@ -2,14 +2,17 @@
2
2
  # Executed over strict SSH from the selected verified Git revision. stdin is
3
3
  # one line holding the base64 fleet configuration (empty when there is none),
4
4
  # then the base64 Git bundle (nothing in cached mode). All executable content
5
- # remains in this tracked source.
5
+ # remains in this tracked source. The Ubuntu releases workers may run are
6
+ # named once, in ansible/ubuntu/policy.json; the manager passes them in.
6
7
  set -eu
7
8
  umask 077
8
9
  revision=$1
9
10
  bundle_sha=$2
10
11
  node=$3
11
12
  source_mode=${4:-transfer}
13
+ releases=${5:-}
12
14
  case "$source_mode" in cached|transfer) ;; *) exit 2;; esac
15
+ case "$releases" in *[!0-9.\ ]*|'') exit 2;; esac
13
16
  case "$revision" in *[!a-f0-9]*|'') exit 2;; esac
14
17
  [ "${#revision}" = 40 ] || exit 2
15
18
  case "$bundle_sha" in *[!a-f0-9]*|'') exit 2;; esac
@@ -26,7 +29,8 @@ remedy() {
26
29
  case "$1" in
27
30
  FLEET_REMOTE_LOCKED) echo 'Another fleet operation holds this machine. Wait for it to finish; rerun fleet-sync afterwards. Never remove the lock while its holder runs.';;
28
31
  ROOT_DEVELOPMENT_USER_REJECTED) echo 'Connect as the non-root developer account the fleet configuration names.';;
29
- UBUNTU_BASELINE_MISMATCH|UBUNTU_ARCHITECTURE_UNSUPPORTED|MACOS_BASELINE_MISMATCH|MACOS_ARCHITECTURE_UNSUPPORTED|PLATFORM_UNSUPPORTED) echo 'Workers run Ubuntu 26.04 on x86_64 or aarch64. Reinstall the machine; fleet-sync never upgrades an operating system.';;
32
+ UBUNTU_BASELINE_MISMATCH|UBUNTU_ARCHITECTURE_UNSUPPORTED|PLATFORM_UNSUPPORTED) echo 'Workers run an Ubuntu release docs/support-matrix.md lists, on x86_64 or aarch64. Reinstall the machine; fleet-sync never upgrades an operating system.';;
33
+ WORKER_ENVIRONMENT_UNSUPPORTED) echo 'WSL2 is not a worker. Run the worker on a physical or virtual machine of its own (docs/support-matrix.md).';;
30
34
  FLEET_STATE_PATH_UNSAFE) echo 'A fleet state directory under ~/.local/share/personal is a symbolic link. Replace it with a real directory.';;
31
35
  BOOTSTRAP_CAPACITY_UNVERIFIED|BOOTSTRAP_DISK_CAPACITY_REQUIRED) echo 'Free at least 2 GiB and 50000 inodes on the home file system, then rerun fleet-sync.';;
32
36
  SOURCE_DIGEST_MISMATCH) echo 'The bundle changed in transit. Rerun fleet-sync; if it repeats, check the SSH route.';;
@@ -37,11 +41,9 @@ remedy() {
37
41
  APT_DISK_FULL) echo 'Package installation ran out of disk space. Free space under /var and /usr, then rerun fleet-sync.';;
38
42
  DPKG_INTERRUPTED) echo 'An earlier package installation that fleet-sync did not start was interrupted. Run sudo dpkg --configure -a once no package process is running, then rerun fleet-sync.';;
39
43
  APT_UPDATE_FAILED|APT_INSTALL_FAILED) echo 'Run sudo apt-get update and install the bootstrap packages by hand to see the error, then rerun fleet-sync.';;
40
- XCODE_COMMAND_LINE_TOOLS_REQUIRED) echo 'Install the Xcode Command Line Tools (xcode-select --install), then rerun fleet-sync.';;
41
- BOOTSTRAP_LOCK_TOOL_REQUIRED) echo 'Install python3 so the operation lock can be taken, then rerun fleet-sync.';;
42
44
  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
45
  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.';;
46
+ 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
47
  *) echo 'Inspect the remote operation lock and rerun the approved revision. No success receipt advanced.';;
46
48
  esac
47
49
  }
@@ -64,22 +66,14 @@ trap '' HUP
64
66
  trap 'exit 1' INT TERM
65
67
  # No shell startup assumptions, and no implicit root development user.
66
68
  [ "$(id -u)" != 0 ] || die ROOT_DEVELOPMENT_USER_REJECTED
67
- # Ubuntu 26.04 is the accepted worker platform; macOS 14+ follows the same
68
- # phases best effort with launchd instead of systemd.
69
- platform=$(uname -s)
70
- case "$platform" in
71
- Linux)
72
- # shellcheck source=/dev/null
73
- . /etc/os-release
74
- [ "$ID" = ubuntu ] && [ "$VERSION_ID" = 26.04 ] || die UBUNTU_BASELINE_MISMATCH
75
- case "$(uname -m)" in x86_64|aarch64) ;; *) die UBUNTU_ARCHITECTURE_UNSUPPORTED;; esac ;;
76
- Darwin)
77
- major=$(sw_vers -productVersion | cut -d. -f1)
78
- case "$major" in ''|*[!0-9]*) die MACOS_BASELINE_MISMATCH;; esac
79
- [ "$major" -ge 14 ] || die MACOS_BASELINE_MISMATCH
80
- case "$(uname -m)" in arm64|x86_64) ;; *) die MACOS_ARCHITECTURE_UNSUPPORTED;; esac ;;
81
- *) die PLATFORM_UNSUPPORTED;;
82
- esac
69
+ # Workers are the Ubuntu releases the policy accepts (docs/support-matrix.md).
70
+ [ "$(uname -s)" = Linux ] || die PLATFORM_UNSUPPORTED
71
+ # shellcheck source=/dev/null
72
+ . /etc/os-release
73
+ [ "$ID" = ubuntu ] || die UBUNTU_BASELINE_MISMATCH
74
+ case " $releases " in *" $VERSION_ID "*) ;; *) die UBUNTU_BASELINE_MISMATCH;; esac
75
+ case "$(uname -m)" in x86_64|aarch64) ;; *) die UBUNTU_ARCHITECTURE_UNSUPPORTED;; esac
76
+ ! grep -qiE 'microsoft|wsl' /proc/sys/kernel/osrelease 2>/dev/null || die WORKER_ENVIRONMENT_UNSUPPORTED
83
77
  root="$HOME/.local/share/personal/fleet"
84
78
  # Refuse symlinked state roots rather than allowing writes through them.
85
79
  for path in "$HOME/.local" "$HOME/.local/share" "$HOME/.local/share/personal" "$root"; do
@@ -89,15 +83,20 @@ mkdir -p "$root"
89
83
  phase=lock
90
84
  [ ! -L "$root/remote-operation.lock" ] || exit 1
91
85
  exec 9>"$root/remote-operation.lock"
92
- # macOS has no flock(1); lock the same descriptor through fcntl. The lock
93
- # follows this shell's open descriptor and is released when it exits.
94
- if command -v flock >/dev/null 2>&1; then
95
- flock -n 9 || die FLEET_REMOTE_LOCKED
96
- else
97
- command -v python3 >/dev/null 2>&1 || die BOOTSTRAP_LOCK_TOOL_REQUIRED
98
- python3 -c 'import fcntl; fcntl.flock(9, fcntl.LOCK_EX | fcntl.LOCK_NB)' 9>&9 || die FLEET_REMOTE_LOCKED
99
- fi
100
- printf '{"version":1,"pid":%s,"revision":"%s","node":"%s"}\n' "$$" "$revision" "$node" >"$root/remote-operation.json"
86
+ # The lock follows this shell's open descriptor and is released when it exits.
87
+ flock -n 9 || die FLEET_REMOTE_LOCKED
88
+ # The operation record: a manager that lost its session reads it to follow
89
+ # this operation to its end instead of starting another (see
90
+ # src/remote-operation.ts). The result of the last operation is beside it.
91
+ started=$(date +%s)
92
+ result="$root/remote-operation-result.json"
93
+ [ ! -L "$root/remote-operation.json" ] && [ ! -L "$result" ] || die FLEET_STATE_PATH_UNSAFE
94
+ rm -f -- "$result"
95
+ operation() {
96
+ 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" &&
97
+ mv -f -- "$root/remote-operation.json.new" "$root/remote-operation.json"
98
+ }
99
+ operation lock
101
100
  # The fleet configuration is installed under the lock, so two overlapping
102
101
  # runs never write it at once. It holds no secret.
103
102
  phase='configuration-install'
@@ -109,6 +108,7 @@ if [ -n "$configuration" ]; then
109
108
  mv "$config_dir/fleet.json.new" "$config_dir/fleet.json" || die CONFIGURATION_INSTALL_FAILED
110
109
  fi
111
110
  phase=resources
111
+ operation resources
112
112
  # A bootstrap floor protects source transfer and minimal prerequisites. The
113
113
  # Workstation baseline subsequently enforces its stricter declared capacity.
114
114
  free_kib=$(df -Pk "$root" | awk 'END {print $4}')
@@ -120,14 +120,9 @@ if [ "$source_mode" = transfer ]; then
120
120
  bundle=$(mktemp "$root/source.XXXXXX")
121
121
  # Leave no executable payload copies; this temporary file is only Git data.
122
122
  base64 -d >"$bundle"
123
- if command -v sha256sum >/dev/null 2>&1; then
124
- printf '%s %s\n' "$bundle_sha" "$bundle" | sha256sum -c - >/dev/null 2>&1 || die SOURCE_DIGEST_MISMATCH
125
- else
126
- [ "$(shasum -a 256 "$bundle" | cut -d' ' -f1)" = "$bundle_sha" ] || die SOURCE_DIGEST_MISMATCH
127
- fi
123
+ printf '%s %s\n' "$bundle_sha" "$bundle" | sha256sum -c - >/dev/null 2>&1 || die SOURCE_DIGEST_MISMATCH
128
124
  fi
129
125
  phase=prerequisites
130
- if [ "$platform" = Linux ]; then
131
126
  missing=''
132
127
  for package in git curl ca-certificates unzip xz-utils build-essential python3 ansible-core; do
133
128
  dpkg-query -W -f='${db:Status-Status}' "$package" 2>/dev/null | grep -qx installed || missing="$missing $package"
@@ -159,12 +154,6 @@ if [ -n "$missing" ]; then
159
154
  rm -f -- "$pending" "$apt_log"
160
155
  apt_log=''
161
156
  fi
162
- else
163
- # Apple Command Line Tools provide git and python3; Ansible runs through the
164
- # pinned uv tool runner installed by the shared mise toolchain below.
165
- xcode-select -p >/dev/null 2>&1 || die XCODE_COMMAND_LINE_TOOLS_REQUIRED
166
- command -v git >/dev/null 2>&1 || die XCODE_COMMAND_LINE_TOOLS_REQUIRED
167
- fi
168
157
  phase=source-verification
169
158
  deployment="$root/revisions/$revision"
170
159
  [ ! -L "$root/revisions" ] && [ ! -L "$deployment" ] || exit 1
@@ -200,6 +189,7 @@ bundle=''
200
189
  clean=$(git -C "$deployment" status --porcelain) || die SOURCE_STATUS_FAILED
201
190
  [ -z "$clean" ] || die SOURCE_CHECKOUT_CONFLICT
202
191
  phase=toolchain
192
+ operation toolchain
203
193
  cd "$deployment"
204
194
  export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:/usr/local/bin:/usr/bin:/bin"
205
195
  status_file="$root/toolchain-status.json"
@@ -211,15 +201,20 @@ ready_file="$root/toolchain-ready"
211
201
  # are still checked (and reinstalled on drift) by node convergence.
212
202
  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
203
  [ -n "$toolchain_key" ] || toolchain_key=$revision
204
+ # The tool profiles of the fleet configuration choose what is installed, so a
205
+ # changed configuration runs the setup again.
206
+ if [ -f "$HOME/.config/dev-fleet/fleet.json" ]; then
207
+ toolchain_key="$toolchain_key$(cksum <"$HOME/.config/dev-fleet/fleet.json" | cut -d' ' -f1)"
208
+ fi
214
209
  if [ "$(cat "$ready_file" 2>/dev/null)" = "$toolchain_key" ] && [ -n "$(find "$ready_file" -mmin -1440 2>/dev/null)" ]; then
215
210
  toolchain_warm=1
216
211
  else
217
212
  toolchain_warm=''
218
213
  rm -f -- "$status_file" "$ready_file"
219
214
  fi
220
- if command -v timeout >/dev/null 2>&1; then bounded='timeout 1800'; else bounded=''; fi
215
+ bounded='timeout 1800'
221
216
  # 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
217
+ if [ -z "$toolchain_warm" ] && ! $bounded bash bootstrap/bootstrap.sh --worker --node "$node" --non-interactive --status-file "$status_file" >/dev/null 2>&1; then
223
218
  tool_phase=$(sed -n 's/.*"phase":"\([a-z0-9-]*\)".*/\1/p' "$status_file" 2>/dev/null) || tool_phase=''
224
219
  case "$tool_phase" in
225
220
  managed-config) die TOOLCHAIN_CONFIG_CONFLICT;;
@@ -254,5 +249,14 @@ for old in $(ls -1t "$root/revisions"); do
254
249
  rm -rf -- "${root:?}/revisions/$old" || true
255
250
  done
256
251
  phase=configuration
252
+ operation configuration
257
253
  trap - EXIT INT TERM
258
- exec mise exec --locked bun -- bun src/node-converge.ts "$node" "$revision"
254
+ # Node convergence writes its evidence to the result file, not to the SSH
255
+ # session: the session may be gone by the time it ends. The evidence is then
256
+ # printed for the manager that is still connected, under the same lock.
257
+ code=0
258
+ mise exec --locked bun -- bun src/node-converge.ts "$node" "$revision" >"$result.partial" 2>"$root/remote-operation.log" || code=$?
259
+ mv -f -- "$result.partial" "$result"
260
+ operation finished ",\"finished\":$(date +%s),\"exit\":$code"
261
+ cat -- "$result" || true
262
+ 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');