@crvouga/dev-fleet 0.5.0 → 0.7.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 (56) hide show
  1. package/README.md +4 -10
  2. package/ansible/macos/manager.yml +39 -2
  3. package/ansible/macos/policy.json +6 -1
  4. package/ansible/monitoring/agent.yml +2 -0
  5. package/ansible/ubuntu/baseline.yml +8 -0
  6. package/ansible/ubuntu/recovery.yml +2 -0
  7. package/ansible/ubuntu/tailscale.yml +2 -0
  8. package/ansible/ubuntu/tasks/github.yml +50 -0
  9. package/bootstrap/worker-auth.ts +6 -7
  10. package/bootstrap/worker-github.ts +55 -44
  11. package/docs/authentication.md +4 -1
  12. package/docs/consumer-setup.md +7 -4
  13. package/docs/github.md +107 -0
  14. package/docs/manager-recovery.md +78 -0
  15. package/docs/operations.md +9 -1
  16. package/docs/runbooks.md +15 -1
  17. package/docs/state.md +38 -6
  18. package/docs/topology.md +4 -5
  19. package/docs/ubuntu-sync.md +5 -5
  20. package/package.json +2 -2
  21. package/schema/check-ids.json +51 -3
  22. package/schema/fleet.schema.json +79 -1
  23. package/src/auth-checks.ts +14 -6
  24. package/src/check-owners.ts +163 -0
  25. package/src/cli/fleet-restore.ts +205 -0
  26. package/src/cli/fleet-state.ts +57 -5
  27. package/src/cli/fleet-sync-manager.ts +62 -15
  28. package/src/cli/fleet-sync-state.ts +18 -8
  29. package/src/cli/fleet-sync.ts +32 -3
  30. package/src/cli/github-verify.ts +57 -0
  31. package/src/cli/register-fleet.ts +33 -0
  32. package/src/cli/registry.ts +21 -1
  33. package/src/definition.ts +11 -1
  34. package/src/github/checks.ts +412 -0
  35. package/src/github/converge.ts +62 -0
  36. package/src/github/copilot.ts +54 -0
  37. package/src/github/credential.ts +21 -0
  38. package/src/github/policy.ts +141 -0
  39. package/src/github/sync.ts +123 -0
  40. package/src/github/workflow.ts +223 -0
  41. package/src/node-converge-linux.ts +3 -2
  42. package/src/node-converge-shared.ts +6 -2
  43. package/src/platform/manager-host.ts +184 -0
  44. package/src/repair-catalog.ts +66 -2
  45. package/src/repair-plan.ts +25 -13
  46. package/src/secrets.ts +58 -13
  47. package/src/service-auth.ts +2 -2
  48. package/src/state/check.ts +7 -0
  49. package/src/state/holder.ts +50 -0
  50. package/src/state/litestream.ts +6 -3
  51. package/src/state/migrations/0001-documents.sql +13 -0
  52. package/src/state/migrations/0002-lease.sql +7 -0
  53. package/src/state/store.ts +125 -26
  54. package/src/state/sync.ts +131 -10
  55. package/src/worker-access-checks.ts +66 -16
  56. package/src/worker-readiness.ts +1 -1
package/README.md CHANGED
@@ -170,16 +170,10 @@ says where the bucket is and the command is passed its credentials:
170
170
  npx devfleet fleet-state # where the state is, and how it stands with the bucket
171
171
  ```
172
172
 
173
- The copy on the manager is disposable. **To replace the manager**, clone your
174
- project on the new machine and run the same two commands:
175
-
176
- ```sh
177
- npm ci
178
- npx devfleet fleet-sync # with the two bucket credentials in the environment
179
- ```
180
-
181
- The roster and the approved revision come back from the bucket, and the
182
- machine is rebuilt from Git.
173
+ The copy on the manager is disposable: the roster and the approved revision
174
+ come back from the bucket, and the machine is rebuilt from Git.
175
+ [Replacing the manager](docs/manager-recovery.md) is the runbook for a new or
176
+ lost manager.
183
177
 
184
178
  Some runs are started by a machine rather than by you: a worker that hands
185
179
  `fleet-sync` to the manager, for example. They get no environment, so save the
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  # Manager power policy. Workers depend on the manager's singleton services
3
- # (CLI proxy, Beszel hub), so the manager never sleeps on AC power. Sleep on
4
- # battery is left to the human. The settings are the `pmset` object in
3
+ # (CLI proxy, Beszel hub), so the manager never sleeps on AC power and starts
4
+ # again by itself when power returns (autorestart). Sleep on battery is left
5
+ # to the human. The settings are the `pmset` object in
5
6
  # policy.json; fleet-sync measures the same object as manager:power. Changing
6
7
  # them needs an administrator: ansible-playbook -K manager.yml
7
8
  - name: Keep the fleet manager awake on AC power
@@ -29,5 +30,41 @@
29
30
  when: observed != item.value
30
31
  changed_when: true
31
32
 
33
+ # Docker Desktop hosts the CLI proxy and the monitoring hub, so it starts at
34
+ # login: after a power cut the manager comes back without a person. The
35
+ # setting is the `dockerDesktop` object in policy.json; fleet-sync measures it
36
+ # as part of manager:boot-recovery. Only that key of the user's settings file
37
+ # is changed.
38
+ - name: Start Docker Desktop at login
39
+ hosts: localhost
40
+ connection: local
41
+ become: false
42
+ gather_facts: false
43
+ vars_files:
44
+ - policy.json
45
+ vars:
46
+ docker_settings: "{{ lookup('ansible.builtin.env', 'HOME') }}/{{ dockerDesktop.settings }}"
47
+ tasks:
48
+ - name: Find the Docker Desktop settings
49
+ ansible.builtin.stat:
50
+ path: '{{ docker_settings }}'
51
+ register: docker_settings_file
52
+ - name: Read the Docker Desktop settings
53
+ ansible.builtin.slurp:
54
+ src: '{{ docker_settings }}'
55
+ register: docker_settings_text
56
+ when: docker_settings_file.stat.exists
57
+ - name: Turn on start at login, keeping every other setting
58
+ ansible.builtin.copy:
59
+ dest: '{{ docker_settings }}'
60
+ content: "{{ current | combine({dockerDesktop.key: true}) | to_nice_json(indent=2) }}\n"
61
+ mode: '0644'
62
+ vars:
63
+ current: '{{ docker_settings_text.content | b64decode | from_json }}'
64
+ # Docker Desktop writes the file the first time it runs.
65
+ when:
66
+ - docker_settings_file.stat.exists
67
+ - (current[dockerDesktop.key] | default(false)) != true
68
+
32
69
  - name: Stage the fleet's T3 policy
33
70
  ansible.builtin.import_playbook: t3-policy.yml
@@ -4,7 +4,8 @@
4
4
  "architectures": ["arm64", "x86_64"],
5
5
  "pmset": {
6
6
  "sleep": "0",
7
- "womp": "1"
7
+ "womp": "1",
8
+ "autorestart": "1"
8
9
  },
9
10
  "beszelDaemon": "dev.devfleet.beszel-agent",
10
11
  "disk": {
@@ -16,5 +17,9 @@
16
17
  "unhealthyChecks": 3,
17
18
  "maxRestartsPerHour": 3,
18
19
  "intervalSeconds": 60
20
+ },
21
+ "dockerDesktop": {
22
+ "settings": "Library/Group Containers/group.com.docker/settings-store.json",
23
+ "key": "AutoStart"
19
24
  }
20
25
  }
@@ -4,6 +4,8 @@
4
4
  connection: local
5
5
  become: true
6
6
  gather_facts: true
7
+ # fleet-repair re-applies this play alone for a failing beszel agent check.
8
+ tags: [monitoring]
7
9
  vars:
8
10
  beszel_lock: "{{ lookup('ansible.builtin.file', 'lock.json') | from_json }}"
9
11
  beszel_arch: "{{ {'x86_64': 'amd64', 'aarch64': 'arm64'}.get(ansible_facts.architecture, 'unsupported') }}"
@@ -8,24 +8,30 @@
8
8
  - policy.json
9
9
  pre_tasks:
10
10
  - ansible.builtin.import_tasks: tasks/release.yml
11
+ tags: [always]
11
12
  - name: Require an explicit non-root user
12
13
  ansible.builtin.assert:
13
14
  that:
14
15
  - development_user is match('^[a-z_][a-z0-9_-]{0,31}$')
15
16
  - development_user != 'root'
16
17
  fail_msg: Unsupported user; name the non-root developer account.
18
+ tags: [always]
17
19
  - name: Read the designated existing account
18
20
  ansible.builtin.getent:
19
21
  database: passwd
20
22
  key: '{{ development_user }}'
23
+ tags: [always]
21
24
  - name: Refuse a different user identity or home
22
25
  ansible.builtin.assert:
23
26
  that:
24
27
  - ansible_facts.getent_passwd[development_user][1] | int == development_uid | int
25
28
  - ansible_facts.getent_passwd[development_user][4] == development_home
29
+ tags: [always]
26
30
  - ansible.builtin.import_tasks: tasks/dpkg.yml
27
31
  # Memory and disk safety must land even when the disk is nearly full.
32
+ # fleet-repair re-applies them alone (--tags disk) for worker:capacity:*.
28
33
  - ansible.builtin.import_tasks: tasks/resources.yml
34
+ tags: [disk]
29
35
  - name: Inspect capacity before package and service changes
30
36
  ansible.builtin.command:
31
37
  argv:
@@ -65,3 +71,5 @@
65
71
  - ansible.builtin.import_tasks: tasks/ssh.yml
66
72
  - ansible.builtin.import_tasks: tasks/credentials.yml
67
73
  when: development_source is defined
74
+ - ansible.builtin.import_tasks: tasks/github.yml
75
+ when: development_source is defined
@@ -4,6 +4,8 @@
4
4
  connection: local
5
5
  become: true
6
6
  gather_facts: false
7
+ # fleet-repair re-applies this play alone for worker:service-recovery.
8
+ tags: [recovery]
7
9
  tasks:
8
10
  - name: Verify the desktop unit runs before adding any recovery trigger
9
11
  ansible.builtin.command:
@@ -4,6 +4,8 @@
4
4
  connection: local
5
5
  become: true
6
6
  gather_facts: true
7
+ # fleet-repair re-applies this play alone for a failing tailscale:* check.
8
+ tags: [network]
7
9
  # The approved version and the signing key's digest: `tailscale` in policy.json.
8
10
  vars_files:
9
11
  - policy.json
@@ -0,0 +1,50 @@
1
+ ---
2
+ # This worker's GitHub sign-in, apart from its Vault renewal: a failure of one
3
+ # never hides or stops the other. The job signs in from the stored credential
4
+ # and adopts a rotated token while the old one still works.
5
+ - name: Declare the GitHub sign-in job
6
+ vars:
7
+ # Every step the job runs (the secret read and four gh calls) may take
8
+ # its full timeout (GITHUB_STEP_TIMEOUT_MS), plus the runtime's start.
9
+ github_step_timeout_seconds: 30
10
+ github_job_steps: 5
11
+ github_job_start_seconds: 30
12
+ ansible.builtin.copy:
13
+ dest: '{{ development_home }}/.config/systemd/user/personal-worker-github.service'
14
+ owner: '{{ development_user }}'
15
+ mode: '0600'
16
+ content: |
17
+ [Unit]
18
+ Description=Sign this worker in to GitHub from its stored credential
19
+ [Service]
20
+ Type=oneshot
21
+ WorkingDirectory={{ development_source }}
22
+ ExecStart={{ development_home }}/.local/bin/mise exec -- bun bootstrap/worker-github.ts
23
+ Environment="PATH={{ development_home }}/.local/share/mise/shims:{{ development_home }}/.local/bin:/usr/local/bin:/usr/bin:/bin"
24
+ UMask=0077
25
+ TimeoutStartSec={{ github_step_timeout_seconds * github_job_steps + github_job_start_seconds }}
26
+ - name: Declare the GitHub sign-in schedule
27
+ ansible.builtin.copy:
28
+ dest: '{{ development_home }}/.config/systemd/user/personal-worker-github.timer'
29
+ owner: '{{ development_user }}'
30
+ mode: '0600'
31
+ content: |
32
+ [Unit]
33
+ Description=Keep this worker signed in to GitHub with the stored credential
34
+ [Timer]
35
+ OnBootSec=2min
36
+ OnUnitActiveSec=10min
37
+ Persistent=true
38
+ [Install]
39
+ WantedBy=timers.target
40
+ - name: Enable this user's GitHub sign-in timer
41
+ become: true
42
+ become_user: '{{ development_user }}'
43
+ environment:
44
+ XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
45
+ ansible.builtin.systemd_service:
46
+ scope: user
47
+ name: personal-worker-github.timer
48
+ daemon_reload: true
49
+ enabled: true
50
+ state: started
@@ -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
 
@@ -179,6 +179,7 @@ Then replace every placeholder. Each field, from
179
179
  | `ubuntu` | `{ "release": "26.04", "architectures": ["amd64", "arm64"] }`: a release of the [support matrix](support-matrix.md). |
180
180
  | `worker.requiredCapabilities` | Normally all of `github`, `vault`, `t3`, `toolchain`, `workstation`, `worker`. |
181
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). |
182
183
  | `worker.vaultUser` | The Vault user each worker's token is issued for. |
183
184
  | `worker.toolchain`, `agentProfile` | `"mise.lock"`, `"proxy"` (fixed). |
184
185
  | `worker.unattendedSudo`, `persistentT3`, `neverSleep` | `true` (fixed policy). |
@@ -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
@@ -377,10 +382,8 @@ and codes; leave out everything private.
377
382
  - **Upgrade the fleet.** `npm install --save-dev --save-exact @crvouga/dev-fleet@latest`,
378
383
  commit, `npx devfleet fleet-sync`. The canary proves the new revision first.
379
384
  Install an older version and sync to roll back.
380
- - **Replace the manager.** On the new Mac: clone the project, `npm ci`, put
381
- the bucket credentials in the environment, `npx devfleet fleet-sync`. The
382
- roster and the approved revision come back from the bucket. See
383
- [state](state.md) for what the bucket does not hold yet.
385
+ - **Replace the manager.** Follow
386
+ [replacing the manager](manager-recovery.md).
384
387
 
385
388
  ## Moving from a pinned commit and a wrapper script
386
389
 
package/docs/github.md ADDED
@@ -0,0 +1,107 @@
1
+ # GitHub on every node
2
+
3
+ Every node, the manager and each worker, signs in to GitHub from a stored
4
+ credential. Nobody runs `gh auth login` on a node, and no login directory is
5
+ copied between machines.
6
+
7
+ ## Where the token is
8
+
9
+ The token is the declared secret `github-token`, read through the secrets
10
+ provider your `fleet.json` names, like every other secret:
11
+
12
+ | Provider | Shared token | Per-node token (`"credential": "per-node"`) |
13
+ | -------- | ------------------------------------------------------------------------- | ------------------------------------------- |
14
+ | `env` | `DEVFLEET_SECRET_GITHUB_TOKEN` | `DEVFLEET_SECRET_GITHUB_TOKEN_<NODE>` |
15
+ | `file` | `github-token` | `github-token-<node>` |
16
+ | `sops` | key `github-token` | key `github-token-<node>` |
17
+ | `vault` | `locations["github-token"]`, or field `GITHUB_TOKEN` beside the proxy key | the same location, field `<FIELD>_<NODE>` |
18
+
19
+ The login it must belong to is `worker.githubLogin`.
20
+
21
+ ## The `github` block
22
+
23
+ All of it is optional:
24
+
25
+ ```json
26
+ "github": {
27
+ "manager": "fleet",
28
+ "credential": "shared",
29
+ "scopes": ["repo", "workflow"],
30
+ "repositories": ["example-org/app"],
31
+ "organizations": ["example-org"],
32
+ "sandbox": "example-org/sandbox"
33
+ }
34
+ ```
35
+
36
+ - `manager`: `"fleet"` (the default) signs the manager in as the fleet login,
37
+ like a worker. `{ "login": "…" }` names a person's own login: it is checked
38
+ and never replaced.
39
+ - `credential`: `"shared"` (one token) or `"per-node"` (one per node, so one
40
+ can be revoked alone and nodes do not share a rate limit).
41
+ - `scopes`, `repositories`, `organizations`: what `github:permissions` and
42
+ `github:org-access` hold the token to.
43
+ - `sandbox`: the repository `fleet-github-verify` may use.
44
+
45
+ ## How a node is signed in
46
+
47
+ `bootstrap/worker-github.ts` runs at every `fleet-sync` and, on a worker, every
48
+ ten minutes from the user timer `personal-worker-github`. It is apart from the
49
+ Vault renewal (`personal-worker-auth`): one failing never stops or hides the
50
+ other. It signs gh in with the stored token when the node is not signed in, or
51
+ when the stored token differs from the one gh holds and is valid for the
52
+ declared login. git asks gh for the token at every use
53
+ (`credential.https://github.com…helper = !gh auth git-credential`, set by
54
+ `ansible/github/git.yml` with the identity of the `git` block on every node),
55
+ so a replaced token reaches git with no change and no second copy on disk.
56
+
57
+ A `fleet-sync` fails with `github:sync` when the node cannot be signed in.
58
+
59
+ ## Rotating the token
60
+
61
+ 1. Issue a new token for the fleet login and put it in the store as
62
+ `github-token`.
63
+ 2. Run `fleet-sync` (the canary worker first, as always), or wait up to ten
64
+ minutes for every node's timer. Each node adopts the new token while the old
65
+ one still works.
66
+ 3. When `github:token-current` passes on every node, revoke the old token.
67
+
68
+ ## Checks
69
+
70
+ | Check | What it says |
71
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
72
+ | `auth:github` | gh answers `auth status` in this context. |
73
+ | `github:principal` | The node is signed in as its declared login (the fleet login, or the manager's personal one). |
74
+ | `github:token-current` | The node uses the token the store holds now (`GITHUB_TOKEN_STALE` otherwise). Not reported for a personal login. |
75
+ | `github:permissions` | The token has every declared scope and can push to every declared repository (`GITHUB_PERMISSION_MISSING: …`). Warns for a fine-grained token, which reports no scopes. |
76
+ | `github:expiry` | Warns 14 days before the token expires, fails 3 days before. |
77
+ | `github:rate-limit` | Warns below 20 % of the rate limit left, with the reset time. |
78
+ | `github:org-access` | Single sign-on has authorised the token for each declared organisation. |
79
+ | `github:git-credential` | git asks gh for its GitHub credential. |
80
+ | `worker:github-sync` | Why a worker is not signed in: `GITHUB_TOKEN_MISSING`, `GITHUB_TOKEN_REJECTED`, `GITHUB_LOGIN_MISMATCH`, `GITHUB_UNREACHABLE`. |
81
+ | `auth:copilot` | Copilot CLI uses the node's GitHub sign-in; fails `not-signed-in` without one and `COPILOT_TOKEN_UNSUPPORTED` for a classic token. |
82
+ | `github:workflow` | From `fleet-github-verify`: the first step of clone, branch, commit, push, pull request, checks and comment that failed. |
83
+
84
+ ## Copilot CLI
85
+
86
+ Copilot CLI takes the token gh holds when it has none of its own, so the
87
+ node's GitHub sign-in is its sign-in. It accepts OAuth, GitHub App user and
88
+ fine-grained tokens with the Copilot Requests permission, not classic
89
+ personal access tokens. Whether the account is entitled to Copilot is not
90
+ measured; no prompt is sent.
91
+
92
+ ## Verifying the whole workflow
93
+
94
+ `devfleet fleet-github-verify` runs, from the machine it is started on, what an
95
+ agent does on the sandbox repository: clone, a uniquely named branch, an empty
96
+ commit with the configured identity, push, a draft pull request, a read of its
97
+ checks, a comment. It then closes the pull request and deletes the branch,
98
+ whatever failed, and reports anything it could not remove.
99
+
100
+ ## Not yet
101
+
102
+ - Short-lived GitHub App installation tokens minted by the manager.
103
+ - A container registry, package registry and tool installer adapter: today
104
+ only git takes its credential from gh.
105
+ - A launchd timer for the manager: it is signed in at every `fleet-sync`.
106
+ - `fleet-github-verify` runs on the machine it is started on; `--node` and a
107
+ scheduled run are not there yet.
@@ -0,0 +1,78 @@
1
+ # Replacing the manager
2
+
3
+ This is the one runbook for a manager that is replaced or lost: stolen, a dead
4
+ disk, or a new machine. Other documents link here and do not describe the
5
+ procedure themselves.
6
+
7
+ ## What must exist off the machine
8
+
9
+ Nothing on the old manager is needed. Before you need this runbook, make sure
10
+ these exist elsewhere:
11
+
12
+ | What | Where |
13
+ | ----------------------------------- | ------------------------------------------------------------------------ |
14
+ | Your project: `fleet.json`, the pin | Its Git remote |
15
+ | The fleet state | The bucket `fleet.json` names under `state` |
16
+ | The bucket's two credentials | Your password manager (never the manager alone) |
17
+ | Secrets | Your `secrets` provider; `file` and `env` secrets must be kept elsewhere |
18
+ | The CLI proxy's encrypted snapshots | Its restic repository (`vault` secrets provider only) |
19
+ | Your Tailscale and Docker accounts | Yours: signing in is the only step only you can do |
20
+
21
+ ## Restore
22
+
23
+ On the new machine, with Git, Node and Docker installed:
24
+
25
+ 1. Give it the manager's local host name: `manager.id` in `fleet.json`
26
+ (`sudo scutil --set LocalHostName ID` on macOS). `fleet-sync` refuses to
27
+ run on any other machine.
28
+ 2. Sign in to Tailscale and approve the device. Sign in to Docker Desktop if
29
+ your plan needs it.
30
+ 3. Fetch the project and check every prerequisite. Nothing is changed:
31
+
32
+ ```sh
33
+ git clone YOUR-PROJECT && cd YOUR-PROJECT && npm ci
34
+ export DEVFLEET_BUCKET_ACCESS_KEY_ID=… DEVFLEET_BUCKET_SECRET_ACCESS_KEY=…
35
+ npx devfleet fleet-restore --dry-run
36
+ ```
37
+
38
+ It fails on anything missing (`restore:*` checks, each with its remedy)
39
+ and lists what will be restored, in order.
40
+
41
+ 4. Restore, then converge:
42
+
43
+ ```sh
44
+ npx devfleet fleet-restore # the fleet state, from the bucket
45
+ npx devfleet fleet-sync --take-over # the manager, then every worker
46
+ npx devfleet fleet-state --init # credentials for runs the manager starts itself
47
+ npx devfleet cliproxy install # the proxy, from its snapshots
48
+ ```
49
+
50
+ The proxy's own steps are in [its migration runbook](../cliproxy/docs/migration.md).
51
+ `--take-over` is needed while the old manager's lease runs (seven days
52
+ after its last change). Use it only once the old machine is gone: an old
53
+ manager that wakes up afterwards is refused with `FLEET_MANAGER_LEASE_HELD`.
54
+
55
+ 5. Adopt the T3 instances again: `npx devfleet t3-apply --import ID`.
56
+
57
+ Workers need nothing: they reach the manager by its Tailscale name, so they
58
+ follow it once the new machine holds that name. If the Tailscale name
59
+ changed, change `manager.network.tailscaleName` in `fleet.json` and run
60
+ `fleet-sync`, which re-points every worker.
61
+
62
+ ## What is lost
63
+
64
+ The monitoring hub's metric history, alerts and settings made by hand. Its
65
+ accounts and systems are recreated. With `env` or `file` secrets, whatever
66
+ was only on the old manager.
67
+
68
+ ## Coming back by itself
69
+
70
+ A manager restarts after a power cut only when it is set up to
71
+ (`manager:boot-recovery` in `fleet-status`): `autorestart` and Docker Desktop
72
+ at login are applied by `ansible/macos/manager.yml`. The per-user services
73
+ need a user session. **With FileVault on, a Mac waits at the login window
74
+ after every restart until a person signs in**, so nothing comes back
75
+ unattended. That is the trade-off: FileVault protects the proxy's logins and
76
+ the bucket credentials on a stolen Mac; automatic login without FileVault
77
+ lets the fleet recover from a power cut alone. `fleet-status` reports
78
+ FileVault as a warning with that consequence; the choice is yours.
@@ -107,7 +107,7 @@ remote nodes' required lists. Run `fleet-nodes --schema` for editor/agent valida
107
107
  | `ssh:agent` | Manager agent exposes an identity; missing agent warns because IdentityFile keys may still work. |
108
108
  | `ssh:session` | Fresh batch SSH session actually runs the Node/Bun probe. No reused multiplexed connection or agent forwarding. |
109
109
  | `ssh:host-key`, `ssh:dns`, `ssh:auth`, `ssh:network`, `ssh:runtime`, `ssh:timeout` | Classified failure and targeted remedy, without raw logs. |
110
- | `auth:github` | gh auth status --active succeeds on this machine; not proof of Git SSH or every repository permission. |
110
+ | `auth:github` | gh auth status --active succeeds; the `github:*` checks ([GitHub](github.md)) say which login, token, permissions and expiry. |
111
111
  | `auth:vault` | Server-side vault token lookup succeeds in this context; not proof of access to every secret path. |
112
112
  | `auth:vscode` | code CLI finds the local Remote-SSH extension. Other profiles/Cursor/GUI PATH/agents can differ. |
113
113
  | `t3:settings` | Connect explicitly enabled and not revoked in t3 status --json; settings are not connectivity. |
@@ -304,6 +304,14 @@ The public CLI exposes `devfleet fleet-repair --list` (static catalog) and
304
304
  in this package only executes allowlisted existing owner commands. Native
305
305
  account logins and unknown/manual checks always remain operator steps.
306
306
 
307
+ Every check ID has an owner in `src/check-owners.ts`: a recipe, or the reason
308
+ only a person can repair it, which the plan prints beside the check
309
+ (`manual`). Machine repairs are one tag of a playbook the worker already
310
+ applies: `recovery` (`worker:service-recovery`), `disk`
311
+ (`worker:capacity:*`), `monitoring` (the Beszel agent) and `network` (the
312
+ Tailscale daemon). `state:*` checks take the fleet state from the bucket
313
+ again. A feature fails when a check is reported that has no owner.
314
+
307
315
  Use `--apply --yes` only after review; `--plan-hash` binds application to the
308
316
  previewed steps, targets and checkout/shared-input fingerprints. The remote
309
317
  protocol independently requires explicit apply, a supported repair action and