@crvouga/dev-fleet 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +3 -1
  2. package/ansible/macos/policy.json +1 -0
  3. package/ansible/platforms.json +111 -0
  4. package/ansible/ubuntu/authorize-sudo.sh +7 -16
  5. package/ansible/ubuntu/baseline.yml +6 -28
  6. package/ansible/ubuntu/disk-gc.py +186 -4
  7. package/ansible/ubuntu/policy.json +18 -3
  8. package/ansible/ubuntu/t3-desktop.yml +2 -2
  9. package/ansible/ubuntu/tailscale.yml +20 -9
  10. package/ansible/ubuntu/tasks/dpkg.yml +46 -0
  11. package/ansible/ubuntu/tasks/github.yml +50 -0
  12. package/ansible/ubuntu/tasks/install.yml +38 -0
  13. package/ansible/ubuntu/tasks/packages.yml +4 -55
  14. package/ansible/ubuntu/tasks/release.yml +25 -0
  15. package/ansible/ubuntu/tasks/resources.yml +6 -5
  16. package/ansible/ubuntu/tasks/snapshot.yml +50 -0
  17. package/ansible/ubuntu/tasks/unattended-upgrades.yml +24 -0
  18. package/bootstrap/fleet-remote-bootstrap.sh +18 -41
  19. package/bootstrap/worker-auth.ts +6 -7
  20. package/bootstrap/worker-github.ts +55 -44
  21. package/docs/authentication.md +4 -1
  22. package/docs/consumer-setup.md +37 -30
  23. package/docs/declared.md +4 -5
  24. package/docs/github.md +107 -0
  25. package/docs/operations.md +1 -1
  26. package/docs/runbooks.md +17 -6
  27. package/docs/support-matrix.md +36 -0
  28. package/docs/ubuntu-sync.md +14 -12
  29. package/docs/worker-contract.md +28 -1
  30. package/package.json +2 -2
  31. package/schema/check-ids.json +55 -7
  32. package/schema/fleet.schema.json +86 -4
  33. package/src/auth-checks.ts +14 -6
  34. package/src/cli/fleet-sync-manager.ts +9 -0
  35. package/src/cli/fleet-sync-remote.ts +1 -1
  36. package/src/cli/github-verify.ts +57 -0
  37. package/src/cli/register-fleet.ts +2 -0
  38. package/src/cli/registry.ts +7 -0
  39. package/src/definition.ts +29 -8
  40. package/src/github/checks.ts +412 -0
  41. package/src/github/converge.ts +62 -0
  42. package/src/github/copilot.ts +54 -0
  43. package/src/github/credential.ts +21 -0
  44. package/src/github/policy.ts +141 -0
  45. package/src/github/sync.ts +123 -0
  46. package/src/github/workflow.ts +223 -0
  47. package/src/machine/platform.ts +48 -28
  48. package/src/machine/support-matrix.ts +76 -0
  49. package/src/manager-sync.ts +15 -7
  50. package/src/network-definition.ts +1 -1
  51. package/src/node-converge-linux.ts +3 -2
  52. package/src/node-converge-shared.ts +6 -2
  53. package/src/node-converge.ts +1 -1
  54. package/src/platform/debian-version.ts +62 -0
  55. package/src/platform/ubuntu-audit.ts +11 -4
  56. package/src/platform/ubuntu-release.ts +41 -0
  57. package/src/remote-bootstrap.ts +2 -0
  58. package/src/repair-catalog.ts +2 -2
  59. package/src/secrets.ts +58 -13
  60. package/src/service-auth.ts +2 -2
  61. package/src/worker-access-checks.ts +66 -16
  62. package/src/worker-host-checks.ts +36 -10
  63. package/src/worker-readiness.ts +1 -1
@@ -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 }}'
@@ -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-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
+ 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
@@ -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";
@@ -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,8 +41,6 @@ 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
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.';;
@@ -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,14 +83,8 @@ 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
86
+ # The lock follows this shell's open descriptor and is released when it exits.
87
+ flock -n 9 || die FLEET_REMOTE_LOCKED
100
88
  # The operation record: a manager that lost its session reads it to follow
101
89
  # this operation to its end instead of starting another (see
102
90
  # src/remote-operation.ts). The result of the last operation is beside it.
@@ -132,14 +120,9 @@ if [ "$source_mode" = transfer ]; then
132
120
  bundle=$(mktemp "$root/source.XXXXXX")
133
121
  # Leave no executable payload copies; this temporary file is only Git data.
134
122
  base64 -d >"$bundle"
135
- if command -v sha256sum >/dev/null 2>&1; then
136
- printf '%s %s\n' "$bundle_sha" "$bundle" | sha256sum -c - >/dev/null 2>&1 || die SOURCE_DIGEST_MISMATCH
137
- else
138
- [ "$(shasum -a 256 "$bundle" | cut -d' ' -f1)" = "$bundle_sha" ] || die SOURCE_DIGEST_MISMATCH
139
- fi
123
+ printf '%s %s\n' "$bundle_sha" "$bundle" | sha256sum -c - >/dev/null 2>&1 || die SOURCE_DIGEST_MISMATCH
140
124
  fi
141
125
  phase=prerequisites
142
- if [ "$platform" = Linux ]; then
143
126
  missing=''
144
127
  for package in git curl ca-certificates unzip xz-utils build-essential python3 ansible-core; do
145
128
  dpkg-query -W -f='${db:Status-Status}' "$package" 2>/dev/null | grep -qx installed || missing="$missing $package"
@@ -171,12 +154,6 @@ if [ -n "$missing" ]; then
171
154
  rm -f -- "$pending" "$apt_log"
172
155
  apt_log=''
173
156
  fi
174
- else
175
- # Apple Command Line Tools provide git and python3; Ansible runs through the
176
- # pinned uv tool runner installed by the shared mise toolchain below.
177
- xcode-select -p >/dev/null 2>&1 || die XCODE_COMMAND_LINE_TOOLS_REQUIRED
178
- command -v git >/dev/null 2>&1 || die XCODE_COMMAND_LINE_TOOLS_REQUIRED
179
- fi
180
157
  phase=source-verification
181
158
  deployment="$root/revisions/$revision"
182
159
  [ ! -L "$root/revisions" ] && [ ! -L "$deployment" ] || exit 1
@@ -235,7 +212,7 @@ else
235
212
  toolchain_warm=''
236
213
  rm -f -- "$status_file" "$ready_file"
237
214
  fi
238
- if command -v timeout >/dev/null 2>&1; then bounded='timeout 1800'; else bounded=''; fi
215
+ bounded='timeout 1800'
239
216
  # shellcheck disable=SC2086
240
217
  if [ -z "$toolchain_warm" ] && ! $bounded bash bootstrap/bootstrap.sh --worker --node "$node" --non-interactive --status-file "$status_file" >/dev/null 2>&1; then
241
218
  tool_phase=$(sed -n 's/.*"phase":"\([a-z0-9-]*\)".*/\1/p' "$status_file" 2>/dev/null) || tool_phase=''
@@ -1,5 +1,7 @@
1
- import { syncWorkerGithub } from './worker-github';
2
- /** Private scheduled job. The worker retains its own token; raw auth output is never logged. */
1
+ /** Private scheduled job: renews this worker's own Vault token, and nothing
2
+ * else. GitHub sign-in is its own job (worker-github.ts), so a failure of
3
+ * one never stops or hides the other. Raw auth output is never logged.
4
+ */
3
5
  import { spawnSync } from 'node:child_process';
4
6
  import { resolveVaultConfig, usesVault } from '../src/vault-config';
5
7
 
@@ -49,14 +51,11 @@ if (import.meta.main) {
49
51
  jobId: 'vault-self-renewal',
50
52
  operation: 'vault.token.renew',
51
53
  });
52
- const vault = renewWorkerVault();
53
- const github = vault && syncWorkerGithub();
54
- const ok = vault && github;
54
+ const ok = renewWorkerVault();
55
55
  log.info('job_finished', {
56
56
  jobId: 'vault-self-renewal',
57
57
  operation: 'vault.token.renew',
58
- vaultAuthenticated: vault,
59
- githubAuthenticated: github,
58
+ vaultAuthenticated: ok,
60
59
  outcome: ok ? 'success' : 'authentication_required',
61
60
  durationMs: Date.now() - started,
62
61
  retryDelayMs: 600000,
@@ -1,47 +1,58 @@
1
- /** Acquire declared service credentials from this worker's Vault session; never copy a login directory. */
2
- import { spawnSync } from 'node:child_process';
3
- import { resolveVaultConfig, usesVault } from '../src/vault-config';
4
- import { fleet as definition } from '../src/fleet-config';
1
+ /** GitHub sign-in of this node from the stored credential; never copies a
2
+ * login directory. Runs from the timer (`personal-worker-github`) and from
3
+ * every sync. One JSON line, never a token or raw output.
4
+ */
5
+ import { hostname } from 'node:os';
6
+ import { fleet } from '../src/fleet-config';
7
+ import { secretsProvider } from '../src/secrets';
8
+ import { runCommand } from '../src/runner';
9
+ import { hostId } from '../src/topology';
10
+ import { githubCredential } from '../src/github/credential';
11
+ import { githubNode } from '../src/github/policy';
12
+ import { syncGithub, type GithubSyncResult } from '../src/github/sync';
5
13
 
6
- export type AuthRunner = (
7
- args: string[],
8
- options?: { input?: string; env?: NodeJS.ProcessEnv }
9
- ) => { status: number | null; stdout: string | null };
10
- const execute: AuthRunner = (args, options = {}) =>
11
- spawnSync(args[0]!, args.slice(1), {
12
- encoding: 'utf8',
13
- timeout: 30_000,
14
- ...options,
15
- });
16
- export function syncWorkerGithub(run: AuthRunner = execute): boolean {
17
- const expected = definition.worker!.githubLogin;
18
- const current = run(['gh', 'api', 'user', '--jq', '.login']);
19
- if (current.status === 0 && current.stdout?.trim() === expected) return true;
20
- // The service token is kept in the shared Vault document only.
21
- if (!usesVault()) return false;
22
- const { addr, mount, project, config } = resolveVaultConfig();
23
- const secret = run([
24
- 'vault',
25
- 'kv',
26
- 'get',
27
- `-address=${addr}`,
28
- '-field=GITHUB_TOKEN',
29
- `${mount}/${project}/${config}`,
30
- ]);
31
- const token = secret.stdout?.trim() ?? '';
32
- if (secret.status !== 0 || !/^[\x21-\x7e]{16,4096}$/.test(token))
33
- return false;
34
- const validated = run(['gh', 'api', 'user', '--jq', '.login'], {
35
- env: { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: undefined },
36
- });
37
- if (validated.status !== 0 || validated.stdout?.trim() !== expected)
38
- return false;
39
- const login = run(
40
- ['gh', 'auth', 'login', '--hostname', 'github.com', '--with-token'],
41
- {
42
- input: token + '\n',
43
- env: { ...process.env, GH_TOKEN: undefined, GITHUB_TOKEN: undefined },
44
- }
14
+ /** The exit status of each outcome, so the unit's result names it too. */
15
+ export const EXIT_CODES: Record<GithubSyncResult['code'], number> = {
16
+ GITHUB_OK: 0,
17
+ GITHUB_TOKEN_MISSING: 10,
18
+ GITHUB_TOKEN_REJECTED: 11,
19
+ GITHUB_LOGIN_MISMATCH: 12,
20
+ GITHUB_UNREACHABLE: 13,
21
+ GITHUB_SIGN_IN_FAILED: 14,
22
+ };
23
+
24
+ export const GITHUB_JOB = 'dev-fleet-github';
25
+
26
+ if (import.meta.main) {
27
+ const started = Date.now();
28
+ // The manager is never a declared worker; `--node` names a worker whose
29
+ // host name is not its id.
30
+ const flag = process.argv.indexOf('--node');
31
+ const id = flag > 0 ? process.argv[flag + 1] : hostId(hostname());
32
+ const target = githubNode(fleet, id);
33
+ const result: GithubSyncResult =
34
+ target.login === null
35
+ ? { code: 'GITHUB_LOGIN_MISMATCH', adopted: false }
36
+ : await syncGithub({
37
+ run: runCommand,
38
+ token: githubCredential(
39
+ fleet,
40
+ target.node,
41
+ secretsProvider(fleet.secrets)
42
+ ),
43
+ login: target.login,
44
+ // A person's own login on the manager is measured, never replaced.
45
+ apply: target.managed,
46
+ });
47
+ process.stdout.write(
48
+ JSON.stringify({
49
+ name: GITHUB_JOB,
50
+ event: 'job_finished',
51
+ role: target.role,
52
+ code: result.code,
53
+ adopted: result.adopted,
54
+ durationMs: Date.now() - started,
55
+ }) + '\n'
45
56
  );
46
- return login.status === 0;
57
+ process.exitCode = EXIT_CODES[result.code];
47
58
  }
@@ -54,6 +54,7 @@ Every failure code is in [the runbooks](runbooks.md).
54
54
  | `monitoring-account-password` | yes | `DEVFLEET_SECRET_MONITORING_ACCOUNT_PASSWORD` | `~/.config/dev-fleet/secrets/monitoring-account-password` |
55
55
  | `monitoring-tunnel-token` | yes | `DEVFLEET_SECRET_MONITORING_TUNNEL_TOKEN` | `~/.config/dev-fleet/secrets/monitoring-tunnel-token` |
56
56
  | `proxy-tunnel-token` | yes | `DEVFLEET_SECRET_PROXY_TUNNEL_TOKEN` | `~/.config/dev-fleet/secrets/proxy-tunnel-token` |
57
+ | `github-token` | when its feature is declared | `DEVFLEET_SECRET_GITHUB_TOKEN` | `~/.config/dev-fleet/secrets/github-token` |
57
58
  | `monitoring-backup-access-key` | when its feature is declared | `DEVFLEET_SECRET_MONITORING_BACKUP_ACCESS_KEY` | `~/.config/dev-fleet/secrets/monitoring-backup-access-key` |
58
59
  | `monitoring-backup-secret-key` | when its feature is declared | `DEVFLEET_SECRET_MONITORING_BACKUP_SECRET_KEY` | `~/.config/dev-fleet/secrets/monitoring-backup-secret-key` |
59
60
  | `notify-webhook-url` | when its feature is declared | `DEVFLEET_SECRET_NOTIFY_WEBHOOK_URL` | `~/.config/dev-fleet/secrets/notify-webhook-url` |
@@ -67,10 +68,11 @@ Every failure code is in [the runbooks](runbooks.md).
67
68
 
68
69
  | Check | Measured in |
69
70
  | ----------------------------- | ------------------------------------------ |
71
+ | `auth:github` | `src/github/checks.ts` |
70
72
  | `auth:vault` | `src/cli/fleet-sync-vault.ts` |
71
73
  | `worker:git-identity` | `src/git-identity.ts` |
72
74
  | `worker:git-push-auth` | `src/definition.ts`, `src/git-identity.ts` |
73
- | `worker:github-principal` | `src/worker-access-checks.ts` |
75
+ | `worker:github-sync` | `src/worker-access-checks.ts` |
74
76
  | `worker:proxy-authentication` | `src/worker-access-checks.ts` |
75
77
  | `worker:vault-renewal` | `src/worker-access-checks.ts` |
76
78
 
@@ -84,6 +86,7 @@ Every failure code is in [the runbooks](runbooks.md).
84
86
  | -------------------------------- | -------------------------------------- | ------------------------------------- |
85
87
  | `personal-fleet-disk-gc.timer` | `ansible/ubuntu/tasks/resources.yml` | OnBootSec=10min, OnCalendar=hourly |
86
88
  | `personal-worker-auth.timer` | `ansible/ubuntu/tasks/credentials.yml` | OnBootSec=2min, OnUnitActiveSec=10min |
89
+ | `personal-worker-github.timer` | `ansible/ubuntu/tasks/github.yml` | OnBootSec=2min, OnUnitActiveSec=10min |
87
90
  | `personal-worker-recovery.timer` | `ansible/ubuntu/tasks/recovery.yml` | OnBootSec=1min, OnUnitActiveSec=1min |
88
91
  | `personal-worker-recovery.timer` | `ansible/ubuntu/tasks/recovery.yml` | OnBootSec=5min, OnUnitActiveSec=15min |
89
92
 
@@ -153,35 +153,36 @@ npx devfleet fleet-state --example > fleet.json
153
153
  Then replace every placeholder. Each field, from
154
154
  [`schema/fleet.schema.json`](../schema/fleet.schema.json):
155
155
 
156
- | Field | Value |
157
- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
158
- | `version` | `3` |
159
- | `source.repository` | `https://github.com/crvouga/dev-fleet.git`, or your fork if you publish one. Machines only accept code from it. |
160
- | `state` | `{ "bucket", "endpoint"?, "region"?, "prefix"? }` or `{ "directory": "/absolute/path" }`. Never credentials. |
161
- | `secrets` | See [secrets](#secrets). |
162
- | `manager.id` | A short lowercase id, `^[a-z][a-z0-9-]{0,62}$`, e.g. `manager-mac`. |
163
- | `manager.os` | `"macOS"` |
164
- | `manager.network.tailscaleName` | The manager's MagicDNS name: `tailscale status --json` → `.Self.DNSName` without the trailing dot. |
165
- | `manager.network.user` | The manager's login name (`id -un`). |
166
- | `manager.neverSleepOnPower` | `true` (workers depend on the manager's services). |
167
- | `workers[].id` | A short lowercase id. The **first** worker is the canary: a new revision must pass there before the others get it. |
168
- | `workers[].ssh` | The worker's alias in the manager's `~/.ssh/config`. |
169
- | `workers[].os` | `"linux"` |
170
- | `workers[].network.tailscaleName` | The worker's MagicDNS name (from `tailscale status --json` → `.Peer[].DNSName` on the manager). |
171
- | `workers[].network.sshPort` | `22` unless the worker's sshd listens elsewhere. |
172
- | `workers[].network.user` | The worker account (`ssh WORKER_ALIAS id -un`). |
173
- | `workers[].network.t3Port` | `3773` |
174
- | `services.proxy.endpoint` | The public HTTPS origin of the CLI proxy, e.g. `https://cli-proxy.example.com`. |
175
- | `services.monitoring.hubPort` | `8443` |
176
- | `services.monitoring.publicUrl` | The public HTTPS origin of the monitoring dashboard. |
177
- | `services.monitoring.managerAccount` | The email of the monitoring account the manager enrolls machines with. |
178
- | `services.monitoring.viewerAccount` | The email of the read-only account visitors are signed in as. |
179
- | `ubuntu` | `{ "release": "26.04", "architectures": ["amd64", "arm64"] }` (fixed). |
180
- | `worker.requiredCapabilities` | Normally all of `github`, `vault`, `t3`, `toolchain`, `workstation`, `worker`. |
181
- | `worker.githubLogin` | The GitHub login every worker must be signed in as. |
182
- | `worker.vaultUser` | The Vault user each worker's token is issued for. |
183
- | `worker.toolchain`, `agentProfile` | `"mise.lock"`, `"proxy"` (fixed). |
184
- | `worker.unattendedSudo`, `persistentT3`, `neverSleep` | `true` (fixed policy). |
156
+ | Field | Value |
157
+ | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
158
+ | `version` | `3` |
159
+ | `source.repository` | `https://github.com/crvouga/dev-fleet.git`, or your fork if you publish one. Machines only accept code from it. |
160
+ | `state` | `{ "bucket", "endpoint"?, "region"?, "prefix"? }` or `{ "directory": "/absolute/path" }`. Never credentials. |
161
+ | `secrets` | See [secrets](#secrets). |
162
+ | `manager.id` | A short lowercase id, `^[a-z][a-z0-9-]{0,62}$`, e.g. `manager-mac`. |
163
+ | `manager.os` | `"macOS"` |
164
+ | `manager.network.tailscaleName` | The manager's MagicDNS name: `tailscale status --json` → `.Self.DNSName` without the trailing dot. |
165
+ | `manager.network.user` | The manager's login name (`id -un`). |
166
+ | `manager.neverSleepOnPower` | `true` (workers depend on the manager's services). |
167
+ | `workers[].id` | A short lowercase id. The **first** worker is the canary: a new revision must pass there before the others get it. |
168
+ | `workers[].ssh` | The worker's alias in the manager's `~/.ssh/config`. |
169
+ | `workers[].os` | `"linux"` |
170
+ | `workers[].network.tailscaleName` | The worker's MagicDNS name (from `tailscale status --json` → `.Peer[].DNSName` on the manager). |
171
+ | `workers[].network.sshPort` | `22` unless the worker's sshd listens elsewhere. |
172
+ | `workers[].network.user` | The worker account (`ssh WORKER_ALIAS id -un`). |
173
+ | `workers[].network.t3Port` | `3773` |
174
+ | `services.proxy.endpoint` | The public HTTPS origin of the CLI proxy, e.g. `https://cli-proxy.example.com`. |
175
+ | `services.monitoring.hubPort` | `8443` |
176
+ | `services.monitoring.publicUrl` | The public HTTPS origin of the monitoring dashboard. |
177
+ | `services.monitoring.managerAccount` | The email of the monitoring account the manager enrolls machines with. |
178
+ | `services.monitoring.viewerAccount` | The email of the read-only account visitors are signed in as. |
179
+ | `ubuntu` | `{ "release": "26.04", "architectures": ["amd64", "arm64"] }`: a release of the [support matrix](support-matrix.md). |
180
+ | `worker.requiredCapabilities` | Normally all of `github`, `vault`, `t3`, `toolchain`, `workstation`, `worker`. |
181
+ | `worker.githubLogin` | The GitHub login every worker must be signed in as. |
182
+ | `github` (optional) | Who the manager signs in to GitHub as, per-node tokens, required scopes; see [GitHub](github.md). |
183
+ | `worker.vaultUser` | The Vault user each worker's token is issued for. |
184
+ | `worker.toolchain`, `agentProfile` | `"mise.lock"`, `"proxy"` (fixed). |
185
+ | `worker.unattendedSudo`, `persistentT3`, `neverSleep` | `true` (fixed policy). |
185
186
 
186
187
  Use the same values in every place they repeat. Validate before going on:
187
188
 
@@ -206,6 +207,10 @@ dev-fleet asks for four secrets by purpose: `proxy-client-key`,
206
207
  | `file` | `{ "provider": "file" }` (optional `"directory"`) | One private file per name, default `~/.config/dev-fleet/secrets/NAME`. |
207
208
  | `env` | `{ "provider": "env" }` | `DEVFLEET_SECRET_PROXY_CLIENT_KEY` and so on (upper case, `_` for `-`). |
208
209
 
210
+ A fifth, `github-token`, is the GitHub token of `worker.githubLogin`: every node
211
+ is signed in with it ([GitHub](github.md)). With `vault` it needs no location
212
+ of its own: it defaults to the field `GITHUB_TOKEN` beside `proxy-client-key`.
213
+
209
214
  Only `vault` gives each worker its own login and renewal and gives the CLI
210
215
  proxy its encrypted backup; with the other three those two features are not
211
216
  available yet. You never write a secret value into the project; the human puts
@@ -352,7 +357,9 @@ Read `summary` and `remedy.action` of the failing check. The common ones:
352
357
  | `TAILSCALE_NODE_OFFLINE` | The worker is off or off the tailnet. It is listed and skipped; others continue. |
353
358
  | `TAILSCALE_DEVICE_REPLACED` | The tailnet device behind a worker changed. Nothing is written to it until the human confirms the replacement. |
354
359
  | `SUDO_AUTHORIZATION_REQUIRED` | The worker account cannot use sudo without a password. Run the first sync interactively. |
355
- | `UBUNTU_BASELINE_MISMATCH` | The worker is not Ubuntu 26.04. |
360
+ | `UBUNTU_BASELINE_MISMATCH` | The worker is not an Ubuntu release the [support matrix](support-matrix.md) lists. |
361
+ | `WORKER_ENVIRONMENT_UNSUPPORTED` | The worker is WSL2 or a container. Use a physical or virtual machine of its own. |
362
+ | `PACKAGE_NEWER_THAN_PIN` (warning) | Ubuntu's security updates moved a pinned package past its pin. It is kept; the pin is a minimum. |
356
363
  | `ROOT_DEVELOPMENT_USER_REJECTED` | The worker's SSH user is root. Use a normal account with sudo. |
357
364
  | `BOOTSTRAP_DISK_CAPACITY_REQUIRED` | Less than 2 GiB free (or too few inodes) where the fleet keeps its files on the worker. |
358
365
  | `FLEET_REMOTE_LOCKED` | Another operation is running on that worker. Wait and run again. |