@crvouga/dev-fleet 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +88 -7
  2. package/agents/audit.md +18 -18
  3. package/ansible/agents/agents.yml +134 -0
  4. package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
  5. package/ansible/agents/templates/claude.settings.json.j2 +3 -0
  6. package/ansible/agents/templates/codex.config.toml.j2 +11 -0
  7. package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
  8. package/ansible/agents/templates/grok.config.toml.j2 +19 -0
  9. package/ansible/agents/templates/opencode.json.j2 +24 -0
  10. package/ansible/agents/templates/receipt.json.j2 +10 -0
  11. package/ansible/ansible.cfg +14 -0
  12. package/ansible/github/git.yml +82 -0
  13. package/ansible/macos/manager-privilege.yml +83 -0
  14. package/ansible/macos/manager.yml +164 -9
  15. package/ansible/macos/policy.json +13 -0
  16. package/ansible/macos/t3-policy.yml +5 -3
  17. package/ansible/macos/vault-renew.yml +85 -0
  18. package/ansible/macos/worker-host-key.yml +33 -0
  19. package/ansible/monitoring/agent-darwin.yml +3 -0
  20. package/ansible/monitoring/agent.yml +2 -0
  21. package/ansible/monitoring/hub-darwin.yml +3 -0
  22. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  23. package/ansible/net/remote-probe.sh +4 -1
  24. package/ansible/platforms.json +2 -2
  25. package/ansible/pyproject.toml +5 -0
  26. package/ansible/ubuntu/baseline.yml +39 -13
  27. package/ansible/ubuntu/disk-gc.py +6 -2
  28. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  29. package/ansible/ubuntu/policy.json +125 -6
  30. package/ansible/ubuntu/reboot.yml +89 -0
  31. package/ansible/ubuntu/recovery.yml +13 -3
  32. package/ansible/ubuntu/t3/policy.json +3 -0
  33. package/ansible/ubuntu/t3-desktop.yml +23 -2
  34. package/ansible/ubuntu/t3-headless.yml +128 -0
  35. package/ansible/ubuntu/t3-health.sh +24 -10
  36. package/ansible/ubuntu/t3-idle.py +37 -2
  37. package/ansible/ubuntu/t3-policy.yml +4 -1
  38. package/ansible/ubuntu/t3-route.yml +1 -0
  39. package/ansible/ubuntu/tailscale.yml +54 -8
  40. package/ansible/ubuntu/tasks/browser.yml +3 -0
  41. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  42. package/ansible/ubuntu/tasks/docker.yml +53 -6
  43. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  44. package/ansible/ubuntu/tasks/features.yml +22 -0
  45. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  46. package/ansible/ubuntu/tasks/install.yml +2 -1
  47. package/ansible/ubuntu/tasks/packages.yml +4 -2
  48. package/ansible/ubuntu/tasks/release.yml +6 -0
  49. package/ansible/ubuntu/tasks/resources.yml +6 -4
  50. package/ansible/ubuntu/tasks/services.yml +2 -2
  51. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  52. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  53. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  54. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  55. package/ansible/ubuntu/tasks/user.yml +1 -0
  56. package/ansible/ubuntu/worker-account.yml +117 -0
  57. package/ansible/uv.lock +265 -0
  58. package/bin/devfleet.mjs +42 -4
  59. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  60. package/bootstrap/global-config.ts +29 -3
  61. package/bootstrap/package-smoke.ts +127 -0
  62. package/bootstrap/pins.ts +189 -0
  63. package/bootstrap/release-notes.ts +88 -0
  64. package/bootstrap/release-version.ts +41 -7
  65. package/bootstrap/worker-features.ts +114 -0
  66. package/bootstrap/worker-seed.yaml +65 -0
  67. package/bootstrap/worker-tools.ts +39 -9
  68. package/cliproxy/README.md +1 -1
  69. package/cliproxy/docs/health-workflow.example.yml +20 -143
  70. package/docs/authentication.md +21 -11
  71. package/docs/consumer-setup.md +74 -58
  72. package/docs/convergence.md +67 -0
  73. package/docs/declared.md +61 -34
  74. package/docs/machine-setup.md +73 -7
  75. package/docs/manager-setup.md +34 -0
  76. package/docs/operations.md +64 -9
  77. package/docs/releasing.md +49 -1
  78. package/docs/runbooks.md +112 -7
  79. package/docs/state.md +23 -11
  80. package/docs/support-matrix.md +40 -4
  81. package/docs/topology.md +82 -12
  82. package/docs/ubuntu-sync.md +98 -42
  83. package/docs/worker-contract.md +122 -19
  84. package/docs/worker-requirements.md +107 -0
  85. package/examples/fleet.json +1 -0
  86. package/examples/workers-from-infrastructure/main.tf +132 -0
  87. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  88. package/llms.txt +3 -1
  89. package/mise.lock +29 -0
  90. package/mise.toml +5 -3
  91. package/package.json +2 -2
  92. package/schema/check-ids.json +1991 -162
  93. package/schema/check-ids.schema.json +34 -0
  94. package/schema/fleet.schema.json +349 -10
  95. package/schema/output/check.schema.json +1 -1
  96. package/schema/output/fleet-state-definition.schema.json +14 -0
  97. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  98. package/schema/output/fleet-sync.schema.json +18 -0
  99. package/schema/output/report.schema.json +3 -2
  100. package/schema/worker-record.schema.json +76 -0
  101. package/src/agents/converge.ts +140 -24
  102. package/src/agents/files.ts +2 -32
  103. package/src/agents/native-verify.ts +34 -3
  104. package/src/agents/secrets.ts +39 -4
  105. package/src/check-owners.ts +62 -2
  106. package/src/check-registry.ts +630 -0
  107. package/src/cli/agent-guide.ts +3 -3
  108. package/src/cli/cli.ts +4 -0
  109. package/src/cli/fleet-manifest.ts +108 -0
  110. package/src/cli/fleet-node-lifecycle.ts +704 -0
  111. package/src/cli/fleet-node-park.ts +238 -0
  112. package/src/cli/fleet-node-seed.ts +167 -0
  113. package/src/cli/fleet-state.ts +2 -1
  114. package/src/cli/fleet-sync-checkout.ts +7 -0
  115. package/src/cli/fleet-sync-enrollment.ts +193 -32
  116. package/src/cli/fleet-sync-forward.ts +6 -0
  117. package/src/cli/fleet-sync-manager.ts +123 -8
  118. package/src/cli/fleet-sync-output.ts +31 -11
  119. package/src/cli/fleet-sync-plan.ts +65 -9
  120. package/src/cli/fleet-sync-records.ts +113 -0
  121. package/src/cli/fleet-sync-remote.ts +172 -15
  122. package/src/cli/fleet-sync-schedule.ts +14 -4
  123. package/src/cli/fleet-sync-state.ts +31 -10
  124. package/src/cli/fleet-sync-vault.ts +152 -26
  125. package/src/cli/fleet-sync-worker.ts +214 -0
  126. package/src/cli/fleet-sync.ts +172 -21
  127. package/src/cli/fleet-validate.ts +277 -0
  128. package/src/cli/fleet-watch.ts +7 -1
  129. package/src/cli/fleet.ts +87 -5
  130. package/src/cli/managed-health.ts +25 -1
  131. package/src/cli/register-fleet.ts +251 -4
  132. package/src/cli/registry.ts +47 -0
  133. package/src/cli/tailnet.ts +43 -10
  134. package/src/cli/ui-data.ts +3 -1
  135. package/src/cli/worker-usage-report.ts +63 -0
  136. package/src/clock.ts +42 -0
  137. package/src/definition.ts +762 -232
  138. package/src/diagnostics.ts +5 -2
  139. package/src/exposure.ts +102 -0
  140. package/src/fleet-config.ts +3 -1
  141. package/src/git-identity.ts +76 -3
  142. package/src/ide/ide.ts +5 -0
  143. package/src/ide/t3.ts +12 -2
  144. package/src/input-hashes.ts +6 -0
  145. package/src/machine/machine-class.ts +59 -0
  146. package/src/machine/platform.ts +90 -11
  147. package/src/machine/state.ts +70 -16
  148. package/src/machine/support-matrix.ts +11 -9
  149. package/src/machine-health.ts +28 -5
  150. package/src/manager-health.ts +148 -2
  151. package/src/manager-sync.ts +89 -8
  152. package/src/monitoring/beszel.ts +8 -8
  153. package/src/monitoring/enroll.ts +8 -1
  154. package/src/monitoring/hub-access.ts +10 -4
  155. package/src/monitoring/systems.ts +138 -0
  156. package/src/network-definition.ts +13 -4
  157. package/src/node-converge-linux.ts +136 -27
  158. package/src/node-converge.ts +13 -3
  159. package/src/platform/ansible-receipt.ts +17 -2
  160. package/src/platform/ansible.ts +40 -4
  161. package/src/platform/manager-host.ts +25 -8
  162. package/src/platform/resource-verify.ts +18 -7
  163. package/src/platform/t3-policy.ts +24 -10
  164. package/src/platform/t3-service.ts +109 -34
  165. package/src/platform/ubuntu-release.ts +24 -0
  166. package/src/platform/ubuntu-verify.ts +31 -3
  167. package/src/platform/ubuntu-worker.ts +58 -0
  168. package/src/platform/worker-platform.ts +72 -0
  169. package/src/protocol.ts +1 -1
  170. package/src/public-reachability.ts +186 -0
  171. package/src/remote-bootstrap.ts +50 -12
  172. package/src/remote-operation.ts +18 -4
  173. package/src/repair-plan.ts +23 -12
  174. package/src/report.ts +2 -1
  175. package/src/schedule-check.ts +21 -2
  176. package/src/secrets.ts +7 -0
  177. package/src/service-auth.ts +55 -48
  178. package/src/ssh-connection.ts +141 -0
  179. package/src/ssh.ts +63 -24
  180. package/src/state/store.ts +4 -2
  181. package/src/state/sync.ts +35 -6
  182. package/src/state/workers.ts +140 -0
  183. package/src/t3/pairing.ts +59 -0
  184. package/src/t3/peer-link.ts +333 -0
  185. package/src/t3/version.ts +1 -1
  186. package/src/tailnet-cleanup.ts +185 -0
  187. package/src/tailnet-control.ts +346 -0
  188. package/src/tailscale.ts +1 -1
  189. package/src/toolchain-checks.ts +11 -1
  190. package/src/types.ts +4 -2
  191. package/src/worker-account.ts +63 -0
  192. package/src/worker-activity.ts +303 -0
  193. package/src/worker-capacity.ts +31 -0
  194. package/src/worker-credentials.ts +201 -0
  195. package/src/worker-exposure.ts +388 -0
  196. package/src/worker-features.ts +201 -0
  197. package/src/worker-host-checks.ts +71 -20
  198. package/src/worker-manifest.ts +224 -0
  199. package/src/worker-parking.ts +70 -0
  200. package/src/worker-policy.ts +38 -36
  201. package/src/worker-readiness.ts +15 -3
  202. package/src/worker-reboot.ts +129 -0
  203. package/src/worker-records.ts +305 -0
  204. package/src/worker-seed.ts +143 -0
  205. package/src/worker-sizing.ts +76 -0
  206. package/src/cli/schedule-logs.ts +0 -36
@@ -10,7 +10,8 @@
10
10
  vars_files:
11
11
  - policy.json
12
12
  tasks:
13
- - ansible.builtin.import_tasks: tasks/release.yml
13
+ - name: Require a supported Ubuntu release
14
+ ansible.builtin.import_tasks: tasks/release.yml
14
15
  - name: Inspect installed packages before possible network interruption
15
16
  ansible.builtin.package_facts:
16
17
  manager: apt
@@ -55,12 +56,16 @@
55
56
  group: root
56
57
  mode: '0644'
57
58
  timeout: 30
59
+ # The same one-line file apt_repository wrote and the first-boot seed
60
+ # writes, without apt_repository's need for gpg, which a minimal image
61
+ # does not have.
58
62
  - name: Declare the signed upstream package source
59
- ansible.builtin.apt_repository:
60
- repo: deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu {{ ubuntu_release.codename }} main
61
- filename: tailscale
62
- state: present
63
- update_cache: false
63
+ ansible.builtin.copy:
64
+ dest: /etc/apt/sources.list.d/tailscale.list
65
+ content: "deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu {{ ubuntu_release.codename }} main\n"
66
+ owner: root
67
+ group: root
68
+ mode: '0644'
64
69
  register: tailscale_source
65
70
  - name: Install the approved version only when absent
66
71
  ansible.builtin.apt:
@@ -81,11 +86,52 @@
81
86
  ansible.builtin.command:
82
87
  argv: [tailscale, status, '--json']
83
88
  changed_when: false
89
+ check_mode: false
84
90
  failed_when: false
85
91
  register: tailscale_status
86
92
  no_log: true
93
+ # A new worker joins with a single-use, pre-authorised, tagged key that
94
+ # the seed or the bootstrap left in a root-only file. It joins only when
95
+ # it has never been logged in (NeedsLogin): a node with Tailscale state
96
+ # keeps its identity, and is never re-joined or reset. The key is read
97
+ # from the file by tailscale itself, so it is never an argument. The tag
98
+ # comes with the key and is never advertised as well: Headscale refuses a
99
+ # pre-authorised join that advertises tags (features/tailnet-box).
100
+ - name: Look for a single-use enrolment key
101
+ ansible.builtin.stat:
102
+ path: /run/devfleet/tailnet-auth-key
103
+ register: tailnet_key
104
+ - name: Join the tailnet as the declared worker, with its tag, when never logged in
105
+ ansible.builtin.command:
106
+ argv: >-
107
+ {{ ['tailscale', 'up', '--auth-key=file:/run/devfleet/tailnet-auth-key',
108
+ '--hostname=' ~ tailnet_hostname, '--ssh=false', '--timeout=120s']
109
+ + (['--login-server=' ~ tailnet_login_server] if tailnet_login_server | default('') else []) }}
110
+ when:
111
+ - tailnet_key.stat.exists
112
+ - tailnet_hostname | default('') is match('^[a-z0-9][a-z0-9-]{0,62}$')
113
+ - tailscale_status.rc == 0
114
+ - (tailscale_status.stdout | from_json).BackendState == 'NeedsLogin'
115
+ changed_when: true
116
+ # A used or expired key fails here; the assertion below names it.
117
+ failed_when: false
118
+ register: tailnet_join
119
+ no_log: true
120
+ - name: Forget the enrolment key once it was offered
121
+ ansible.builtin.file:
122
+ path: /run/devfleet/tailnet-auth-key
123
+ state: absent
124
+ - name: Inspect native authentication after the join
125
+ ansible.builtin.command:
126
+ argv: [tailscale, status, '--json']
127
+ changed_when: false
128
+ check_mode: false
129
+ failed_when: false
130
+ register: tailscale_joined
131
+ no_log: true
132
+ when: tailnet_join is changed
87
133
  - name: Require genuine device authentication before claiming connectivity
88
134
  ansible.builtin.assert:
89
- that: (tailscale_status.stdout | from_json).BackendState == 'Running'
90
- fail_msg: TAILSCALE_LOGIN_REQUIRED; authorize this device through its native login. Existing state is never copied or reset.
135
+ that: ((tailscale_joined if tailnet_join is changed else tailscale_status).stdout | from_json).BackendState == 'Running'
136
+ fail_msg: TAILSCALE_LOGIN_REQUIRED; authorize this device through its native login, or declare tailnet in fleet.json so fleet-sync enrols it with a single-use key. Existing state is never copied or reset.
91
137
  no_log: true
@@ -51,6 +51,9 @@
51
51
  - --disable-gpu
52
52
  - --dump-dom
53
53
  - about:blank
54
+ become: true
54
55
  become_user: '{{ development_user }}'
55
56
  timeout: '{{ browser.launchTimeoutSeconds }}'
56
57
  changed_when: false
58
+ # Verifies a change a dry run does not make.
59
+ when: not ansible_check_mode
@@ -0,0 +1,43 @@
1
+ ---
2
+ # A worker whose `containers` feature is off (fleet.json features.containers)
3
+ # keeps none of what tasks/docker.yml and the pinned packages gave it: the
4
+ # developer leaves the docker group, the restart drop-in goes, and the Ubuntu
5
+ # Docker packages dev-fleet pinned are removed (not purged: images and volumes
6
+ # under /var/lib/docker stay until a person deletes them). A Docker that
7
+ # another distribution owns (docker-ce) was never dev-fleet's and is left
8
+ # alone. fleet-sync --dry-run lists what this removes; the worker:features
9
+ # check reports what it removed.
10
+ - name: Read the members of the groups containers gave the developer
11
+ ansible.builtin.getent:
12
+ database: group
13
+ key: '{{ item }}'
14
+ fail_key: false
15
+ loop: '{{ features.containers.groups }}'
16
+ register: container_groups
17
+ - name: Take the developer out of the groups containers gave it
18
+ ansible.builtin.command:
19
+ argv: [gpasswd, --delete, '{{ development_user }}', '{{ item.item }}']
20
+ loop: '{{ container_groups.results }}'
21
+ loop_control:
22
+ label: '{{ item.item }}'
23
+ when: >-
24
+ (item.ansible_facts.getent_group[item.item] | default(none)) is not none
25
+ and development_user in (item.ansible_facts.getent_group[item.item][2] | default('')).split(',')
26
+ changed_when: true
27
+ - name: Remove the restart drop-in of the container services
28
+ ansible.builtin.file:
29
+ path: '/etc/systemd/system/{{ item }}.service.d/60-personal-fleet.conf'
30
+ state: absent
31
+ loop: '{{ features.containers.services }}'
32
+ register: container_dropins
33
+ - name: Reload unit definitions after removing the drop-ins
34
+ ansible.builtin.systemd_service:
35
+ daemon_reload: true
36
+ when: container_dropins.changed
37
+ - name: Remove the Docker packages dev-fleet pinned
38
+ ansible.builtin.apt:
39
+ name: '{{ features.containers.packages }}'
40
+ state: absent
41
+ purge: false
42
+ autoremove: false
43
+ lock_timeout: '{{ aptLockTimeoutSeconds }}'
@@ -29,14 +29,26 @@
29
29
  - [docker, compose, version]
30
30
  - [docker, buildx, version]
31
31
  changed_when: false
32
+ # Verifies a change a dry run does not make.
33
+ when: not ansible_check_mode
32
34
  # The engine answering is not proof that an agent can use it: run a pinned
33
35
  # image and build a one-line image as the developer, in a fresh session that
34
36
  # reads the docker group from scratch. The image is cached here so the same
35
37
  # test works offline in worker:docker-run.
38
+ #
39
+ # The fresh session is made here, by runuser as root, and not by
40
+ # `become_user`: Ansible skips becoming the user who already runs the play,
41
+ # which the developer does on a worker. The command then ran in the session
42
+ # that started before the group was granted, and the first sync of a new
43
+ # worker ended with "permission denied" on the Docker socket.
36
44
  - name: Look for the cached smoke-test image
37
45
  ansible.builtin.command:
38
46
  argv:
39
47
  [
48
+ runuser,
49
+ -u,
50
+ '{{ development_user }}',
51
+ --,
40
52
  docker,
41
53
  image,
42
54
  inspect,
@@ -44,21 +56,37 @@
44
56
  '{{ "{{.Id}}" }}',
45
57
  '{{ dockerSmokeTest.image }}',
46
58
  ]
47
- become_user: '{{ development_user }}'
59
+ environment:
60
+ HOME: '{{ development_home }}'
48
61
  check_mode: false
49
62
  changed_when: false
50
63
  failed_when: false
51
64
  register: docker_smoke_image
52
65
  - name: Cache the pinned smoke-test image
53
66
  ansible.builtin.command:
54
- argv: [docker, pull, --quiet, '{{ dockerSmokeTest.image }}']
55
- become_user: '{{ development_user }}'
67
+ argv:
68
+ [
69
+ runuser,
70
+ -u,
71
+ '{{ development_user }}',
72
+ --,
73
+ docker,
74
+ pull,
75
+ --quiet,
76
+ '{{ dockerSmokeTest.image }}',
77
+ ]
78
+ environment:
79
+ HOME: '{{ development_home }}'
56
80
  when: docker_smoke_image.rc | default(0) != 0
57
81
  changed_when: true
58
82
  - name: Run a container as the developer, offline (DOCKER_RUN_FAILED)
59
83
  ansible.builtin.command:
60
84
  argv:
61
85
  [
86
+ runuser,
87
+ -u,
88
+ '{{ development_user }}',
89
+ --,
62
90
  docker,
63
91
  run,
64
92
  --rm,
@@ -69,15 +97,34 @@
69
97
  '{{ dockerSmokeTest.image }}',
70
98
  'true',
71
99
  ]
72
- become_user: '{{ development_user }}'
100
+ environment:
101
+ HOME: '{{ development_home }}'
73
102
  timeout: '{{ dockerSmokeTest.timeoutSeconds }}'
74
103
  changed_when: false
104
+ # Verifies a change a dry run does not make.
105
+ when: not ansible_check_mode
75
106
  - name: Build a one-line image as the developer, offline (DOCKER_BUILD_FAILED)
76
107
  ansible.builtin.command:
77
- argv: [docker, build, --quiet, --pull=false, --network, none, '-']
108
+ argv:
109
+ [
110
+ runuser,
111
+ -u,
112
+ '{{ development_user }}',
113
+ --,
114
+ docker,
115
+ build,
116
+ --quiet,
117
+ --pull=false,
118
+ --network,
119
+ none,
120
+ '-',
121
+ ]
78
122
  stdin: |
79
123
  FROM {{ dockerSmokeTest.image }}
80
124
  RUN true
81
- become_user: '{{ development_user }}'
125
+ environment:
126
+ HOME: '{{ development_home }}'
82
127
  timeout: '{{ dockerSmokeTest.timeoutSeconds }}'
83
128
  changed_when: false
129
+ # Verifies a change a dry run does not make.
130
+ when: not ansible_check_mode
@@ -13,12 +13,14 @@
13
13
  fcntl.lockf(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
14
14
  register: apt_idle
15
15
  changed_when: false
16
+ check_mode: false
16
17
  retries: 24
17
18
  delay: 5
18
19
  until: apt_idle.rc == 0
19
20
  - name: Inspect package database consistency
20
21
  ansible.builtin.command: dpkg --audit
21
22
  changed_when: false
23
+ check_mode: false
22
24
  register: dpkg_audit
23
25
  # Power lost during a package run leaves dpkg half-configured. Finishing
24
26
  # that run is the standard safe repair: it is tried once, bounded, after
@@ -30,11 +32,13 @@
30
32
  - name: Finish configuring every unpacked package, bounded
31
33
  ansible.builtin.command:
32
34
  argv: [timeout, '600', dpkg, --configure, -a]
35
+ changed_when: true
33
36
  environment:
34
37
  DEBIAN_FRONTEND: noninteractive
35
38
  - name: Inspect package database consistency after the repair
36
39
  ansible.builtin.command: dpkg --audit
37
40
  changed_when: false
41
+ check_mode: false
38
42
  register: dpkg_reaudit
39
43
  rescue:
40
44
  - name: Report a package run the repair could not finish
@@ -0,0 +1,22 @@
1
+ ---
2
+ # The declared worker features (fleet.json `features`, resolved by
3
+ # bootstrap/worker-features.ts): `worker_containers` and `worker_session` are
4
+ # passed by fleet-sync. Absent, a worker has containers and a desktop session,
5
+ # as every worker had before features existed. What a feature that is off
6
+ # would install is left out here, as policy.json `features` lists it.
7
+ - name: Require a known session type
8
+ ansible.builtin.assert:
9
+ that: worker_session | default('desktop') in ['desktop', 'headless']
10
+ fail_msg: WORKER_FEATURES_INVALID; the session is desktop or headless (fleet.json features.session).
11
+ tags: [always]
12
+ - name: Select what the features this worker lacks would install
13
+ ansible.builtin.set_fact:
14
+ worker_containers_on: '{{ worker_containers | default(true) | bool }}'
15
+ worker_headless: "{{ worker_session | default('desktop') == 'headless' }}"
16
+ worker_packages_off: "{{ ([] if worker_containers | default(true) | bool else features.containers.packages) + (features.desktop.packages if worker_session | default('desktop') == 'headless' else []) }}"
17
+ worker_always_on_services: '{{ alwaysOnServices | difference([] if worker_containers | default(true) | bool else features.containers.services) }}'
18
+ tags: [always]
19
+ - name: Pin only the packages of the features this worker has
20
+ ansible.builtin.set_fact:
21
+ packages: "{{ packages | dict2items | rejectattr('key', 'in', worker_packages_off) | items2dict }}"
22
+ tags: [always]
@@ -0,0 +1,172 @@
1
+ ---
2
+ # Inbound traffic reaches a worker only over loopback and the tailnet, apart
3
+ # from what tailscaled itself needs and what the exposure policy opens
4
+ # (worker_exposure, src/exposure.ts). The same rules apply with or without a
5
+ # public address. Measured as worker:exposure.
6
+ #
7
+ # The manager is never locked out. The public SSH port closes only when the
8
+ # session running this play arrived over the tailnet; a session from a public
9
+ # address, a tailnet that is not up, or a run with no session that finds the
10
+ # port open keeps it open (worker:exposure then reports
11
+ # WORKER_PUBLIC_SSH_UNDECLARED). Closing it is verified with new connections
12
+ # over the tailnet, and a failed verification restores the previous rules.
13
+ - name: Read the exposure policy of this worker
14
+ ansible.builtin.set_fact:
15
+ firewall_policy: "{{ {'publicSsh': false, 'publicPorts': []} | combine(worker_exposure | default({})) }}"
16
+ firewall_session_client: "{{ (lookup('ansible.builtin.env', 'SSH_CONNECTION') | default('', true)).split(' ') | first }}"
17
+ - name: Inspect the managed firewall rules
18
+ ansible.builtin.stat:
19
+ path: /etc/personal-fleet/firewall.nft
20
+ register: firewall_owned_file
21
+ - name: Read the previous rules for rollback
22
+ ansible.builtin.slurp:
23
+ src: /etc/personal-fleet/firewall.nft
24
+ when: firewall_owned_file.stat.exists
25
+ register: firewall_previous
26
+ - name: Refuse to overwrite unmanaged firewall rules
27
+ ansible.builtin.assert:
28
+ that:
29
+ - not firewall_owned_file.stat.exists or (firewall_previous.content | b64decode).startswith('# Managed by personal Fleet')
30
+ fail_msg: FIREWALL_UNMANAGED_CONFLICT; review the existing rules file before adoption.
31
+ - name: Read the ports sshd listens on
32
+ ansible.builtin.command: /usr/sbin/sshd -T
33
+ changed_when: false
34
+ check_mode: false
35
+ register: firewall_sshd
36
+ - name: Read this worker's tailnet address
37
+ ansible.builtin.command:
38
+ argv: [tailscale, ip, '-4']
39
+ changed_when: false
40
+ check_mode: false
41
+ failed_when: false
42
+ register: firewall_tailnet_address
43
+ - name: Inspect the tailnet interface
44
+ ansible.builtin.stat:
45
+ path: '/sys/class/net/{{ exposure.tailnetInterface }}'
46
+ register: firewall_tailnet_interface
47
+ - name: Look for Docker, whose published ports the rules close
48
+ ansible.builtin.stat:
49
+ path: /usr/lib/systemd/system/docker.service
50
+ register: firewall_docker_unit
51
+ - name: Decide whether the public SSH path may close
52
+ vars:
53
+ tailnet_up: '{{ firewall_tailnet_address.rc == 0 and firewall_tailnet_address.stdout | trim | length > 0 and firewall_tailnet_interface.stat.exists }}'
54
+ session_over_tailnet: "{{ firewall_session_client is match('^100\\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\\.') or firewall_session_client is match('(?i)^fd7a:115c:a1e0:') }}"
55
+ previously_public: '{{ not firewall_owned_file.stat.exists or ''comment "public-ssh"'' in (firewall_previous.content | b64decode) }}'
56
+ ansible.builtin.set_fact:
57
+ firewall_docker: '{{ firewall_docker_unit.stat.exists }}'
58
+ firewall_ssh_ports: "{{ firewall_sshd.stdout_lines | select('match', '^port [0-9]+$') | map('regex_replace', '^port ', '') | list }}"
59
+ firewall_tailnet_ip: '{{ firewall_tailnet_address.stdout | trim }}'
60
+ firewall_verify_tailnet: '{{ tailnet_up and session_over_tailnet }}'
61
+ firewall_public_ssh: >-
62
+ {{ firewall_policy.publicSsh or not tailnet_up
63
+ or (firewall_session_client | length > 0 and not session_over_tailnet)
64
+ or (firewall_session_client | length == 0 and previously_public) }}
65
+ - name: Create the managed configuration directory
66
+ ansible.builtin.file:
67
+ path: /etc/personal-fleet
68
+ state: directory
69
+ owner: root
70
+ group: root
71
+ mode: '0755'
72
+ - name: Declare the unit that loads the rules at boot
73
+ ansible.builtin.copy:
74
+ dest: /etc/systemd/system/personal-fleet-firewall.service
75
+ owner: root
76
+ group: root
77
+ mode: '0644'
78
+ content: |
79
+ # Managed by personal fleet-sync: ansible/ubuntu/tasks/firewall.yml
80
+ [Unit]
81
+ Description=Personal Fleet firewall (inbound only over loopback and the tailnet)
82
+ DefaultDependencies=no
83
+ Wants=network-pre.target
84
+ Before=network-pre.target shutdown.target
85
+ Conflicts=shutdown.target
86
+ [Service]
87
+ Type=oneshot
88
+ RemainAfterExit=yes
89
+ ExecStart=/usr/sbin/nft -f /etc/personal-fleet/firewall.nft
90
+ ExecReload=/usr/sbin/nft -f /etc/personal-fleet/firewall.nft
91
+ [Install]
92
+ WantedBy=sysinit.target
93
+ register: firewall_unit
94
+ - name: Read the rules the kernel holds now
95
+ ansible.builtin.command:
96
+ argv: [nft, list, table, inet, personal_fleet]
97
+ changed_when: false
98
+ check_mode: false
99
+ failed_when: false
100
+ register: firewall_loaded
101
+ - name: Apply the rules and verify the tailnet path, or restore the previous ones
102
+ block:
103
+ - name: Write the rules, validated by nft (FIREWALL_RULES_REJECTED)
104
+ ansible.builtin.template:
105
+ src: '{{ playbook_dir }}/firewall.nft.j2'
106
+ dest: /etc/personal-fleet/firewall.nft
107
+ owner: root
108
+ group: root
109
+ mode: '0644'
110
+ validate: /usr/sbin/nft --check --file %s
111
+ register: firewall_rules
112
+ - name: Load the rules
113
+ ansible.builtin.systemd_service:
114
+ name: personal-fleet-firewall.service
115
+ daemon_reload: '{{ firewall_unit.changed }}'
116
+ enabled: true
117
+ state: "{{ 'restarted' if firewall_rules.changed or firewall_unit.changed or firewall_loaded.rc != 0 else 'started' }}"
118
+ - name: Verify the manager's tailnet path with a new probe (FIREWALL_TAILNET_UNVERIFIED)
119
+ ansible.builtin.command:
120
+ argv:
121
+ [
122
+ tailscale,
123
+ ping,
124
+ '--c=1',
125
+ '--timeout=5s',
126
+ '{{ firewall_session_client }}',
127
+ ]
128
+ changed_when: false
129
+ when: firewall_verify_tailnet | bool and not firewall_public_ssh | bool and not ansible_check_mode
130
+ - name: Verify sshd accepts a new connection on the tailnet address (FIREWALL_TAILNET_UNVERIFIED)
131
+ ansible.builtin.wait_for:
132
+ host: '{{ firewall_tailnet_ip }}'
133
+ port: '{{ firewall_ssh_ports | first | default(22) | int }}'
134
+ search_regex: SSH-
135
+ timeout: 10
136
+ when: firewall_verify_tailnet | bool and not firewall_public_ssh | bool and not ansible_check_mode
137
+ rescue:
138
+ - name: Restore the previous rules
139
+ ansible.builtin.copy:
140
+ content: '{{ firewall_previous.content | b64decode }}'
141
+ dest: /etc/personal-fleet/firewall.nft
142
+ owner: root
143
+ group: root
144
+ mode: '0644'
145
+ when: firewall_owned_file.stat.exists
146
+ - name: Reload the previous rules
147
+ ansible.builtin.systemd_service:
148
+ name: personal-fleet-firewall.service
149
+ state: restarted
150
+ when: firewall_owned_file.stat.exists
151
+ - name: Remove the rejected first rules
152
+ ansible.builtin.file:
153
+ path: /etc/personal-fleet/firewall.nft
154
+ state: absent
155
+ when: not firewall_owned_file.stat.exists
156
+ - name: Stop applying rules that were never verified
157
+ ansible.builtin.systemd_service:
158
+ name: personal-fleet-firewall.service
159
+ enabled: false
160
+ state: stopped
161
+ when: not firewall_owned_file.stat.exists
162
+ - name: Drop the unverified table from the kernel
163
+ ansible.builtin.command:
164
+ argv: [nft, delete, table, inet, personal_fleet]
165
+ changed_when: true
166
+ failed_when: false
167
+ when: not firewall_owned_file.stat.exists
168
+ - name: Report the failure with the previous rules in place
169
+ ansible.builtin.fail:
170
+ msg: >-
171
+ {{ 'FIREWALL_RULES_REJECTED' if firewall_rules is defined and firewall_rules is failed else 'FIREWALL_TAILNET_UNVERIFIED' }};
172
+ the previous rules are back in place. Check tailscale status on the worker, then rerun fleet-sync.
@@ -4,7 +4,8 @@
4
4
  # package Ubuntu's security updates moved past its pin is left as it is and
5
5
  # reported as a warning (docs/worker-contract.md). `install_pins` maps each
6
6
  # package to its pinned version.
7
- - ansible.builtin.import_tasks: snapshot.yml
7
+ - name: Point APT at the pinned snapshot
8
+ ansible.builtin.import_tasks: snapshot.yml
8
9
  - name: Read the installed package versions
9
10
  ansible.builtin.package_facts:
10
11
  manager: apt
@@ -1,5 +1,7 @@
1
1
  ---
2
- - ansible.builtin.include_tasks: install.yml
2
+ - name: Install the pinned packages
3
+ ansible.builtin.include_tasks: install.yml
3
4
  vars:
4
5
  install_pins: '{{ packages }}'
5
- - ansible.builtin.import_tasks: unattended-upgrades.yml
6
+ - name: Configure unattended upgrades
7
+ ansible.builtin.import_tasks: unattended-upgrades.yml
@@ -12,9 +12,15 @@
12
12
  - ansible_facts.distribution_release == ubuntu_release.codename
13
13
  - "(ansible_facts.architecture | replace('x86_64', 'amd64') | replace('aarch64', 'arm64')) in ubuntu_release.architectures"
14
14
  fail_msg: UBUNTU_BASELINE_MISMATCH; workers run a release docs/support-matrix.md lists. No distribution upgrade is permitted.
15
+ # What differs between accepted releases is data of the release's entry in
16
+ # policy.json, selected above by the release the facts report: its package
17
+ # pins, its browser library pins and, where Tailscale signs its suite with
18
+ # another key, that key's digest. Everything not named there is common.
15
19
  - name: Pin the packages of this release, its own pins over the common ones
16
20
  ansible.builtin.set_fact:
17
21
  packages: '{{ packages | combine(ubuntu_release.packages | default({})) }}'
22
+ browser: "{{ browser | combine({'packages': browser.packages | combine(ubuntu_release.browserPackages | default({}))}) }}"
23
+ tailscale: "{{ tailscale | combine({'keySha256': ubuntu_release.tailscaleKeySha256 | default(tailscale.keySha256)}) }}"
18
24
  - name: Read the kernel release to tell WSL2 apart from a machine of its own
19
25
  ansible.builtin.slurp:
20
26
  src: /proc/sys/kernel/osrelease
@@ -20,12 +20,12 @@
20
20
  when: active_swap.stdout | trim == ''
21
21
  - name: Refuse to create swap on a nearly full disk
22
22
  ansible.builtin.assert:
23
- that: (swap_capacity.stdout_lines | last | trim | regex_replace('M$', '') | int) >= resourceSafety.swapFileMiB + minimumFreeDiskMiB
23
+ that: (swap_capacity.stdout_lines | last | trim | regex_replace('M$', '') | int) >= worker_swap_file_mib | int + worker_minimum_free_disk_mib | int
24
24
  fail_msg: SWAP_CAPACITY_REQUIRED; free disk space so a swap file can be created. No swap is active.
25
25
  when: active_swap.stdout | trim == ''
26
26
  - name: Create a swap file when the node has no swap
27
27
  ansible.builtin.command:
28
- argv: [fallocate, -l, '{{ resourceSafety.swapFileMiB }}M', /swap.fleet]
28
+ argv: [fallocate, -l, '{{ worker_swap_file_mib }}M', /swap.fleet]
29
29
  creates: /swap.fleet
30
30
  when: active_swap.stdout | trim == ''
31
31
  - name: Restrict the fleet swap file
@@ -39,10 +39,12 @@
39
39
  ansible.builtin.command:
40
40
  argv: [mkswap, /swap.fleet]
41
41
  when: active_swap.stdout | trim == ''
42
+ changed_when: true
42
43
  - name: Activate the fleet swap file
43
44
  ansible.builtin.command:
44
45
  argv: [swapon, /swap.fleet]
45
46
  when: active_swap.stdout | trim == ''
47
+ changed_when: true
46
48
  - name: Activate the fleet swap file at boot
47
49
  ansible.builtin.lineinfile:
48
50
  path: /etc/fstab
@@ -225,7 +227,7 @@
225
227
  StartLimitIntervalSec=0
226
228
  [Service]
227
229
  Type=simple
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 }}
230
+ ExecStart=/usr/bin/python3 -I -B {{ disk_guard_source.stat.path }} --path / --path {{ development_home }} --floor-mib {{ worker_disk_guard_floor_mib }} --inode-floor-percent {{ resourceSafety.inodeFailFreePercent }} --write-mib {{ resourceSafety.diskGuardWriteMiB }} --interval {{ resourceSafety.diskGuardIntervalSeconds }}
229
231
  Restart=always
230
232
  RestartSec=5
231
233
  OOMScoreAdjust=-1000
@@ -277,7 +279,7 @@
277
279
  After=docker.service
278
280
  [Service]
279
281
  Type=oneshot
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 }}
282
+ ExecStart=/usr/bin/python3 -I -B {{ disk_gc_source.stat.path }} --home {{ development_home }} --path / --path {{ development_home }} --idle-days {{ resourceSafety.diskGcIdleDays }} --pressure-mib {{ worker_disk_gc_pressure_mib }} --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 }}{{ '' if worker_containers_on | default(true) | bool else ' --no-docker' }}
281
283
 
282
284
  TimeoutStartSec=2h
283
285
  Nice=19
@@ -8,7 +8,7 @@
8
8
  owner: root
9
9
  group: root
10
10
  mode: '0755'
11
- loop: '{{ alwaysOnServices }}'
11
+ loop: '{{ worker_always_on_services | default(alwaysOnServices) }}'
12
12
  - name: Declare unlimited automatic restart for required system services
13
13
  ansible.builtin.copy:
14
14
  dest: '/etc/systemd/system/{{ item }}.service.d/60-personal-fleet.conf'
@@ -23,7 +23,7 @@
23
23
  [Service]
24
24
  Restart=always
25
25
  RestartSec=5
26
- loop: '{{ alwaysOnServices }}'
26
+ loop: '{{ worker_always_on_services | default(alwaysOnServices) }}'
27
27
  register: recovery_dropins
28
28
  - name: Reload unit definitions without restarting running services
29
29
  ansible.builtin.systemd_service:
@@ -0,0 +1,49 @@
1
+ ---
2
+ # The size of this machine against the stated minimum, and the disk and memory
3
+ # lines of the worker policy: each a share of the root filesystem or of memory
4
+ # between a lower and an upper bound (policy.json `sizing`, the same numbers
5
+ # src/worker-sizing.ts computes). Runs before anything is installed.
6
+ - name: Measure the root filesystem
7
+ ansible.builtin.command:
8
+ argv:
9
+ - /usr/bin/python3
10
+ - '-c'
11
+ - |
12
+ import os
13
+ v = os.statvfs('/')
14
+ print((v.f_blocks * v.f_frsize) // (1024 * 1024))
15
+ changed_when: false
16
+ check_mode: false
17
+ register: worker_root_size
18
+ tags: [always]
19
+ - name: Record the size of this machine
20
+ ansible.builtin.set_fact:
21
+ worker_size:
22
+ memoryMiB: '{{ ansible_facts.memtotal_mb | int }}'
23
+ diskMiB: '{{ worker_root_size.stdout | trim | int }}'
24
+ cpus: '{{ ansible_facts.processor_vcpus | int }}'
25
+ tags: [always]
26
+ - name: Refuse a machine below the stated minimum worker size before installing anything
27
+ ansible.builtin.assert:
28
+ that:
29
+ - worker_size.memoryMiB | int >= sizing.minimumWorker.memoryMiB
30
+ - worker_size.diskMiB | int >= sizing.minimumWorker.diskMiB
31
+ - worker_size.cpus | int >= sizing.minimumWorker.cpus
32
+ fail_msg: WORKER_SIZE_BELOW_MINIMUM; a worker needs {{ sizing.minimumWorker.memoryMiB }} MiB of memory, a {{ sizing.minimumWorker.diskMiB }} MiB root filesystem and {{ sizing.minimumWorker.cpus }} CPUs (docs/worker-contract.md). Resize the machine; nothing was installed.
33
+ tags: [always]
34
+ - name: Compute the disk and memory lines for this machine
35
+ ansible.builtin.set_fact:
36
+ worker_thresholds: "{{ worker_thresholds | default({}) | combine({item.key: [[((item.value.share * ((worker_size.memoryMiB if item.value.of == 'memory' else worker_size.diskMiB) | int)) | int), item.value.minMiB] | max, item.value.maxMiB] | min}) }}"
37
+ loop: '{{ sizing.thresholds | dict2items }}'
38
+ loop_control:
39
+ label: '{{ item.key }}'
40
+ tags: [always]
41
+ # Each line may be given as an extra variable instead (the VM test injects a
42
+ # capacity gate this way); an extra variable always wins over a fact.
43
+ - name: Name the lines the tasks use
44
+ ansible.builtin.set_fact:
45
+ worker_swap_file_mib: '{{ worker_thresholds.swapFile }}'
46
+ worker_minimum_free_disk_mib: '{{ worker_thresholds.minimumFreeDisk }}'
47
+ worker_disk_guard_floor_mib: '{{ worker_thresholds.diskGuardFloor }}'
48
+ worker_disk_gc_pressure_mib: '{{ worker_thresholds.diskGcPressure }}'
49
+ tags: [always]
@@ -23,8 +23,18 @@
23
23
  Components: main universe restricted multiverse
24
24
  Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
25
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.
26
+ # The snapshot's indexes live in a directory of their own. An apt update
27
+ # of the system's own sources (unattended upgrades, the Tailscale play)
28
+ # removes the index files of every source it does not know, so kept beside
29
+ # them the snapshot was fetched again on every sync; and fetching the
30
+ # snapshot leaves the system's indexes alone.
31
+ - name: Create the snapshot's own index directory
32
+ ansible.builtin.file:
33
+ path: /var/lib/personal-fleet/apt-lists/partial
34
+ state: directory
35
+ owner: root
36
+ group: root
37
+ mode: '0755'
28
38
  - name: Isolate snapshot selection from unrelated APT operations
29
39
  ansible.builtin.copy:
30
40
  dest: /etc/personal-fleet/apt.conf
@@ -34,11 +44,12 @@
34
44
  content: |
35
45
  Dir::Etc::sourcelist "/etc/personal-fleet/ubuntu.sources";
36
46
  Dir::Etc::sourceparts "-";
47
+ Dir::State::Lists "/var/lib/personal-fleet/apt-lists/";
37
48
  APT::Get::List-Cleanup "false";
38
49
  DPkg::Lock::Timeout "{{ aptLockTimeoutSeconds }}";
39
50
  - name: Inspect immutable snapshot package index
40
51
  ansible.builtin.stat:
41
- path: '/var/lib/apt/lists/snapshot.ubuntu.com_ubuntu_{{ snapshot }}_dists_{{ ubuntu_release.codename }}_InRelease'
52
+ path: '/var/lib/personal-fleet/apt-lists/snapshot.ubuntu.com_ubuntu_{{ snapshot }}_dists_{{ ubuntu_release.codename }}_InRelease'
42
53
  register: snapshot_index
43
54
  - name: Fetch signed snapshot indexes after source change or interrupted setup
44
55
  ansible.builtin.apt: