@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.
- package/README.md +4 -10
- package/ansible/macos/manager.yml +39 -2
- package/ansible/macos/policy.json +6 -1
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/ubuntu/baseline.yml +8 -0
- package/ansible/ubuntu/recovery.yml +2 -0
- package/ansible/ubuntu/tailscale.yml +2 -0
- package/ansible/ubuntu/tasks/github.yml +50 -0
- package/bootstrap/worker-auth.ts +6 -7
- package/bootstrap/worker-github.ts +55 -44
- package/docs/authentication.md +4 -1
- package/docs/consumer-setup.md +7 -4
- package/docs/github.md +107 -0
- package/docs/manager-recovery.md +78 -0
- package/docs/operations.md +9 -1
- package/docs/runbooks.md +15 -1
- package/docs/state.md +38 -6
- package/docs/topology.md +4 -5
- package/docs/ubuntu-sync.md +5 -5
- package/package.json +2 -2
- package/schema/check-ids.json +51 -3
- package/schema/fleet.schema.json +79 -1
- package/src/auth-checks.ts +14 -6
- package/src/check-owners.ts +163 -0
- package/src/cli/fleet-restore.ts +205 -0
- package/src/cli/fleet-state.ts +57 -5
- package/src/cli/fleet-sync-manager.ts +62 -15
- package/src/cli/fleet-sync-state.ts +18 -8
- package/src/cli/fleet-sync.ts +32 -3
- package/src/cli/github-verify.ts +57 -0
- package/src/cli/register-fleet.ts +33 -0
- package/src/cli/registry.ts +21 -1
- package/src/definition.ts +11 -1
- package/src/github/checks.ts +412 -0
- package/src/github/converge.ts +62 -0
- package/src/github/copilot.ts +54 -0
- package/src/github/credential.ts +21 -0
- package/src/github/policy.ts +141 -0
- package/src/github/sync.ts +123 -0
- package/src/github/workflow.ts +223 -0
- package/src/node-converge-linux.ts +3 -2
- package/src/node-converge-shared.ts +6 -2
- package/src/platform/manager-host.ts +184 -0
- package/src/repair-catalog.ts +66 -2
- package/src/repair-plan.ts +25 -13
- package/src/secrets.ts +58 -13
- package/src/service-auth.ts +2 -2
- package/src/state/check.ts +7 -0
- package/src/state/holder.ts +50 -0
- package/src/state/litestream.ts +6 -3
- package/src/state/migrations/0001-documents.sql +13 -0
- package/src/state/migrations/0002-lease.sql +7 -0
- package/src/state/store.ts +125 -26
- package/src/state/sync.ts +131 -10
- package/src/worker-access-checks.ts +66 -16
- 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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
4
|
-
#
|
|
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
|
|
@@ -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
|
package/bootstrap/worker-auth.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// The
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
57
|
+
process.exitCode = EXIT_CODES[result.code];
|
|
47
58
|
}
|
package/docs/authentication.md
CHANGED
|
@@ -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-
|
|
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
|
|
package/docs/consumer-setup.md
CHANGED
|
@@ -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.**
|
|
381
|
-
|
|
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.
|
package/docs/operations.md
CHANGED
|
@@ -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
|
|
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
|