@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { userInfo } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import macos from '../../ansible/macos/policy.json';
|
|
5
|
+
import { record } from '../config';
|
|
6
|
+
import { hostId } from '../topology';
|
|
7
|
+
import { ansibleCommand } from './ansible';
|
|
8
|
+
import type { Runner } from '../types';
|
|
9
|
+
|
|
10
|
+
/** One thing a manager needs to come back by itself after a power cut. */
|
|
11
|
+
export interface BootItem {
|
|
12
|
+
item: string;
|
|
13
|
+
/** `fail` stops unattended recovery; `warn` is a trade-off to know about. */
|
|
14
|
+
status: 'pass' | 'warn' | 'fail';
|
|
15
|
+
detail: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Where the manager runs, behind one interface: how it is named, how it is
|
|
19
|
+
* kept awake, how its services are supervised and where they keep data. The
|
|
20
|
+
* macOS host is the default and the reference.
|
|
21
|
+
*/
|
|
22
|
+
export interface ManagerHost {
|
|
23
|
+
platform: 'macOS' | 'linux';
|
|
24
|
+
serviceManager: 'launchd' | 'systemd';
|
|
25
|
+
containerEngine: 'Docker Desktop' | 'Docker Engine';
|
|
26
|
+
/** The Tailscale command line. */
|
|
27
|
+
tailscale: string[];
|
|
28
|
+
/** Where the manager's services keep their data. */
|
|
29
|
+
dataDirectory(home: string): string;
|
|
30
|
+
/** The machine's local host name, as `manager.id` must name it. */
|
|
31
|
+
localHostName(run: Runner): Promise<string | null>;
|
|
32
|
+
/** The power settings that differ from the manager's policy. */
|
|
33
|
+
powerDrift(run: Runner): Promise<string[]>;
|
|
34
|
+
/** What brings the manager's services back after a power cut, unattended. */
|
|
35
|
+
bootRecovery(run: Runner, home: string): Promise<BootItem[]>;
|
|
36
|
+
/** How a person applies what bootRecovery measures. */
|
|
37
|
+
bootRemedy(workspace: string): string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const item = (
|
|
41
|
+
name: string,
|
|
42
|
+
ok: boolean,
|
|
43
|
+
[pass, failure, status = 'fail']: [string, string, ('warn' | 'fail')?]
|
|
44
|
+
): BootItem => ({
|
|
45
|
+
item: name,
|
|
46
|
+
status: ok ? 'pass' : status,
|
|
47
|
+
detail: ok ? pass : failure,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** `pmset -g custom`'s AC Power section, as key and value. */
|
|
51
|
+
async function acPower(
|
|
52
|
+
run: Runner
|
|
53
|
+
): Promise<(key: string) => string | undefined> {
|
|
54
|
+
const result = await run(['pmset', '-g', 'custom']);
|
|
55
|
+
const ac = result.ok ? (result.stdout.split('AC Power:')[1] ?? '') : '';
|
|
56
|
+
return (key) => new RegExp(`^\\s*${key}\\s+(\\S+)`, 'm').exec(ac)?.[1];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function dockerSettings(home: string): Record<string, unknown> | null {
|
|
60
|
+
try {
|
|
61
|
+
const value: unknown = JSON.parse(
|
|
62
|
+
readFileSync(join(home, macos.dockerDesktop.settings), 'utf8')
|
|
63
|
+
);
|
|
64
|
+
return record(value) ? value : null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const MACOS_MANAGER: ManagerHost = {
|
|
71
|
+
platform: 'macOS',
|
|
72
|
+
serviceManager: 'launchd',
|
|
73
|
+
containerEngine: 'Docker Desktop',
|
|
74
|
+
tailscale: ['tailscale'],
|
|
75
|
+
dataDirectory: (home) => join(home, 'Library/Application Support'),
|
|
76
|
+
async localHostName(run) {
|
|
77
|
+
const result = await run(['scutil', '--get', 'LocalHostName']);
|
|
78
|
+
return result.ok ? hostId(result.stdout.trim()) : null;
|
|
79
|
+
},
|
|
80
|
+
async powerDrift(run) {
|
|
81
|
+
const observed = await acPower(run);
|
|
82
|
+
return Object.entries(macos.pmset)
|
|
83
|
+
.filter(([key, value]) => observed(key) !== value)
|
|
84
|
+
.map(([key]) => key);
|
|
85
|
+
},
|
|
86
|
+
async bootRecovery(run, home) {
|
|
87
|
+
const observed = await acPower(run);
|
|
88
|
+
const docker = dockerSettings(home);
|
|
89
|
+
const vault = await run(['fdesetup', 'status']);
|
|
90
|
+
const filevault = vault.ok && /FileVault is On/.test(vault.stdout);
|
|
91
|
+
const login = await run([
|
|
92
|
+
'defaults',
|
|
93
|
+
'read',
|
|
94
|
+
'/Library/Preferences/com.apple.loginwindow',
|
|
95
|
+
'autoLoginUser',
|
|
96
|
+
]);
|
|
97
|
+
const agent = await run([
|
|
98
|
+
'launchctl',
|
|
99
|
+
'print',
|
|
100
|
+
`gui/${userInfo().uid}/${macos.beszelDaemon}`,
|
|
101
|
+
]);
|
|
102
|
+
return [
|
|
103
|
+
item('autorestart', observed('autorestart') === macos.pmset.autorestart, [
|
|
104
|
+
'The Mac starts again by itself when power returns.',
|
|
105
|
+
'The Mac stays off after a power cut (pmset autorestart is not 1).',
|
|
106
|
+
]),
|
|
107
|
+
item('docker-autostart', docker?.[macos.dockerDesktop.key] === true, [
|
|
108
|
+
'Docker Desktop starts at login, and with it the proxy and the hub.',
|
|
109
|
+
'Docker Desktop does not start at login: the CLI proxy and the monitoring hub stay down after a restart.',
|
|
110
|
+
]),
|
|
111
|
+
filevault
|
|
112
|
+
? item('login', false, [
|
|
113
|
+
'',
|
|
114
|
+
'FileVault is on: after a restart the Mac waits at the login window until a person signs in, and no LaunchAgent or Docker Desktop starts before that. Turn FileVault off for unattended recovery, or accept that a power cut needs a person (docs/manager-recovery.md).',
|
|
115
|
+
'warn',
|
|
116
|
+
])
|
|
117
|
+
: item('login', login.ok && login.stdout.trim().length > 0, [
|
|
118
|
+
'FileVault is off and a user signs in automatically after a restart.',
|
|
119
|
+
'No user signs in automatically after a restart, so no LaunchAgent starts. Turn on automatic login for the manager’s user in System Settings (it asks for that user’s password).',
|
|
120
|
+
'warn',
|
|
121
|
+
]),
|
|
122
|
+
item('launch-agents', agent.ok, [
|
|
123
|
+
'The fleet’s LaunchAgents are loaded.',
|
|
124
|
+
`The LaunchAgent ${macos.beszelDaemon} is not loaded.`,
|
|
125
|
+
]),
|
|
126
|
+
];
|
|
127
|
+
},
|
|
128
|
+
bootRemedy: (workspace) =>
|
|
129
|
+
`Apply the manager policy, as an administrator: cd ${workspace} && ${ansibleCommand('darwin').join(' ')} -K ansible/macos/manager.yml (autorestart, Docker Desktop at login), then fleet-sync (LaunchAgents). FileVault and automatic login are a person’s choice: docs/manager-recovery.md.`,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** The sleep states a Linux manager must never enter. */
|
|
133
|
+
const SLEEP_TARGETS = [
|
|
134
|
+
'sleep.target',
|
|
135
|
+
'suspend.target',
|
|
136
|
+
'hibernate.target',
|
|
137
|
+
'hybrid-sleep.target',
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
export const LINUX_MANAGER: ManagerHost = {
|
|
141
|
+
platform: 'linux',
|
|
142
|
+
serviceManager: 'systemd',
|
|
143
|
+
containerEngine: 'Docker Engine',
|
|
144
|
+
tailscale: ['tailscale'],
|
|
145
|
+
dataDirectory: (home) => join(home, '.local/share'),
|
|
146
|
+
async localHostName(run) {
|
|
147
|
+
const result = await run(['hostnamectl', '--static']);
|
|
148
|
+
return result.ok ? hostId(result.stdout.trim()) : null;
|
|
149
|
+
},
|
|
150
|
+
async powerDrift(run) {
|
|
151
|
+
const drifted: string[] = [];
|
|
152
|
+
for (const target of SLEEP_TARGETS) {
|
|
153
|
+
const result = await run(['systemctl', 'is-enabled', target]);
|
|
154
|
+
if (result.stdout.trim() !== 'masked') drifted.push(target);
|
|
155
|
+
}
|
|
156
|
+
return drifted;
|
|
157
|
+
},
|
|
158
|
+
async bootRecovery(run) {
|
|
159
|
+
const user = userInfo().username;
|
|
160
|
+
const linger = await run([
|
|
161
|
+
'loginctl',
|
|
162
|
+
'show-user',
|
|
163
|
+
user,
|
|
164
|
+
'--property=Linger',
|
|
165
|
+
]);
|
|
166
|
+
const docker = await run(['systemctl', 'is-enabled', 'docker.service']);
|
|
167
|
+
return [
|
|
168
|
+
item('linger', linger.stdout.trim() === 'Linger=yes', [
|
|
169
|
+
'The manager’s user services start at boot, without a login.',
|
|
170
|
+
'The manager’s user services wait for a login (loginctl linger is off).',
|
|
171
|
+
]),
|
|
172
|
+
item('docker-autostart', docker.stdout.trim() === 'enabled', [
|
|
173
|
+
'Docker Engine starts at boot, and with it the proxy and the hub.',
|
|
174
|
+
'Docker Engine does not start at boot: the CLI proxy and the monitoring hub stay down after a restart.',
|
|
175
|
+
]),
|
|
176
|
+
];
|
|
177
|
+
},
|
|
178
|
+
bootRemedy: () =>
|
|
179
|
+
'Enable lingering for the manager’s user and the docker service with the Linux manager policy; a Linux manager role is not shipped yet.',
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export function managerHost(platform: 'macOS' | 'linux'): ManagerHost {
|
|
183
|
+
return platform === 'linux' ? LINUX_MANAGER : MACOS_MANAGER;
|
|
184
|
+
}
|
package/src/repair-catalog.ts
CHANGED
|
@@ -20,6 +20,29 @@ const login = (
|
|
|
20
20
|
action,
|
|
21
21
|
commands,
|
|
22
22
|
});
|
|
23
|
+
/** "Run play X with tag Y on host Z": every machine repair is a playbook the
|
|
24
|
+
* worker already applies, restricted to the tag that owns the failing check.
|
|
25
|
+
*/
|
|
26
|
+
const playbook = ({
|
|
27
|
+
id,
|
|
28
|
+
title,
|
|
29
|
+
path,
|
|
30
|
+
tag,
|
|
31
|
+
action,
|
|
32
|
+
}: {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
path: string;
|
|
36
|
+
tag: string;
|
|
37
|
+
action: string;
|
|
38
|
+
}): RepairRecipe => ({
|
|
39
|
+
id,
|
|
40
|
+
title,
|
|
41
|
+
owner: `${path} (tag ${tag})`,
|
|
42
|
+
automatic: false,
|
|
43
|
+
action: `${action} Runs on the worker, in its fleet-owned checkout, with the variables fleet-sync wrote; devfleet fleet-sync --node NODE re-applies the whole policy instead.`,
|
|
44
|
+
commands: [['ansible-playbook', path, '--tags', tag]],
|
|
45
|
+
});
|
|
23
46
|
export const REPAIR_RECIPES: RepairRecipe[] = [
|
|
24
47
|
{
|
|
25
48
|
id: 'workspace',
|
|
@@ -141,8 +164,8 @@ export const REPAIR_RECIPES: RepairRecipe[] = [
|
|
|
141
164
|
login(
|
|
142
165
|
'copilot',
|
|
143
166
|
'Sign in to Copilot',
|
|
144
|
-
[
|
|
145
|
-
'
|
|
167
|
+
[],
|
|
168
|
+
'Copilot CLI uses the node’s GitHub sign-in, which fleet-sync brings from the stored credential (github-token). Run fleet-sync; store a fine-grained token with the Copilot Requests permission if auth:copilot says the token kind is refused. No per-node copilot login.'
|
|
146
169
|
),
|
|
147
170
|
login(
|
|
148
171
|
'gemini',
|
|
@@ -183,6 +206,47 @@ export const REPAIR_RECIPES: RepairRecipe[] = [
|
|
|
183
206
|
['bun', 'run', 'password-manager', 'actions', 'list'],
|
|
184
207
|
],
|
|
185
208
|
},
|
|
209
|
+
playbook({
|
|
210
|
+
id: 'recovery',
|
|
211
|
+
title: 'Re-apply the worker recovery policy',
|
|
212
|
+
path: 'ansible/ubuntu/recovery.yml',
|
|
213
|
+
tag: 'recovery',
|
|
214
|
+
action:
|
|
215
|
+
'Restores the restart and boot triggers of the worker services (worker:service-recovery). Bounded to the recovery play; it never reboots the worker.',
|
|
216
|
+
}),
|
|
217
|
+
playbook({
|
|
218
|
+
id: 'disk',
|
|
219
|
+
title: 'Re-apply the worker disk and memory safety policy',
|
|
220
|
+
path: 'ansible/ubuntu/baseline.yml',
|
|
221
|
+
tag: 'disk',
|
|
222
|
+
action:
|
|
223
|
+
'Restores the disk guard and the disk-gc timer that reclaims caches (worker:capacity:*). Bounded to the resource tasks; it deletes nothing outside the caches disk-gc owns.',
|
|
224
|
+
}),
|
|
225
|
+
playbook({
|
|
226
|
+
id: 'monitoring',
|
|
227
|
+
title: 'Re-apply the worker monitoring agent',
|
|
228
|
+
path: 'ansible/monitoring/agent.yml',
|
|
229
|
+
tag: 'monitoring',
|
|
230
|
+
action:
|
|
231
|
+
'Reinstalls the pinned Beszel agent and restarts its unit, keeping its runtime identity (beszel:agent, beszel:service).',
|
|
232
|
+
}),
|
|
233
|
+
playbook({
|
|
234
|
+
id: 'network',
|
|
235
|
+
title: 'Re-apply the worker Tailscale daemon',
|
|
236
|
+
path: 'ansible/ubuntu/tailscale.yml',
|
|
237
|
+
tag: 'network',
|
|
238
|
+
action:
|
|
239
|
+
'Reinstalls the approved Tailscale and starts its daemon, keeping the device identity. Signing in again (tailscale up) stays with a person.',
|
|
240
|
+
}),
|
|
241
|
+
{
|
|
242
|
+
id: 'state',
|
|
243
|
+
title: 'Take the fleet state from the bucket again',
|
|
244
|
+
owner: 'devfleet fleet-state',
|
|
245
|
+
automatic: false,
|
|
246
|
+
action:
|
|
247
|
+
'On the manager: devfleet fleet-state --restore replaces the local copy with the bucket’s (add --yes to give up changes the bucket does not hold). A corrupt replica is restored from an earlier time with --timestamp; see docs/state.md.',
|
|
248
|
+
commands: [['devfleet', 'fleet-state', '--restore']],
|
|
249
|
+
},
|
|
186
250
|
{
|
|
187
251
|
id: 'operator',
|
|
188
252
|
title: 'Resolve the check with its owning tool',
|
package/src/repair-plan.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkOwner } from './check-owners';
|
|
1
2
|
import { AUTOMATED_REPAIRS, recipe } from './repair-catalog';
|
|
2
3
|
import type { RepairRecipe } from './repair-catalog';
|
|
3
4
|
import type { Check, FleetNode, NodeReport, Fingerprint } from './types';
|
|
@@ -13,21 +14,26 @@ export interface RepairStep extends RepairRecipe {
|
|
|
13
14
|
checks: string[];
|
|
14
15
|
eligible: boolean;
|
|
15
16
|
next: string[];
|
|
17
|
+
/** For work left to a person: why no recipe does it, per check. */
|
|
18
|
+
manual?: { check: string; reason: string }[];
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export function repairId(check: Check): string {
|
|
19
|
-
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
return
|
|
22
|
+
const owner = checkOwner(check.id);
|
|
23
|
+
if (!owner?.repair) return 'operator';
|
|
24
|
+
return owner.repair === 'login:*'
|
|
25
|
+
? `login:${check.id.slice('auth:'.length)}`
|
|
26
|
+
: owner.repair;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Why a check is left to a person, or null when a recipe repairs it. */
|
|
30
|
+
export function manualReason(check: Check): string | null {
|
|
31
|
+
const owner = checkOwner(check.id);
|
|
32
|
+
if (owner?.repair) return null;
|
|
33
|
+
return (
|
|
34
|
+
owner?.manual ??
|
|
35
|
+
'No repair recipe is registered for this check: follow its remedy.'
|
|
36
|
+
);
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
/** Every nonpassing check is accounted for; unknown checks remain operator work. */
|
|
@@ -44,9 +50,14 @@ export function repairPlan(
|
|
|
44
50
|
const repair = recipe(repairId(check)).id;
|
|
45
51
|
groups.set(repair, [...(groups.get(repair) ?? []), check]);
|
|
46
52
|
}
|
|
47
|
-
for (const [id, checks] of groups)
|
|
53
|
+
for (const [id, checks] of groups) {
|
|
54
|
+
const manual = checks.flatMap((check) => {
|
|
55
|
+
const reason = manualReason(check);
|
|
56
|
+
return reason ? [{ check: check.id, reason }] : [];
|
|
57
|
+
});
|
|
48
58
|
steps.push({
|
|
49
59
|
...recipe(id),
|
|
60
|
+
...(manual.length ? { manual } : {}),
|
|
50
61
|
node: report.node,
|
|
51
62
|
eligible:
|
|
52
63
|
targets.some((target) => target.name === report.node) &&
|
|
@@ -60,6 +71,7 @@ export function repairPlan(
|
|
|
60
71
|
),
|
|
61
72
|
],
|
|
62
73
|
});
|
|
74
|
+
}
|
|
63
75
|
}
|
|
64
76
|
return steps;
|
|
65
77
|
}
|
package/src/secrets.ts
CHANGED
|
@@ -12,10 +12,13 @@ export const SECRET_NAMES = [
|
|
|
12
12
|
'monitoring-tunnel-token',
|
|
13
13
|
'proxy-tunnel-token',
|
|
14
14
|
] as const;
|
|
15
|
-
/** Secrets
|
|
16
|
-
*
|
|
15
|
+
/** Secrets a fleet may leave undeclared. A Vault fleet that names no location
|
|
16
|
+
* for the GitHub token keeps it where dev-fleet always read it (see
|
|
17
|
+
* `vaultLocation`); the others are needed only by a feature the fleet
|
|
18
|
+
* declares, and are otherwise unavailable.
|
|
17
19
|
*/
|
|
18
20
|
export const OPTIONAL_SECRET_NAMES = [
|
|
21
|
+
'github-token',
|
|
19
22
|
// The off-machine storage of the monitoring hub's backups
|
|
20
23
|
// (services.monitoring.backups).
|
|
21
24
|
'monitoring-backup-access-key',
|
|
@@ -52,16 +55,53 @@ export type SecretCommand = (
|
|
|
52
55
|
) => Promise<string | null>;
|
|
53
56
|
|
|
54
57
|
/** One read of one secret for this node. Null when it is not available; the
|
|
55
|
-
* value is never logged, printed or placed in argv.
|
|
58
|
+
* value is never logged, printed or placed in argv. `node` names a node's own
|
|
59
|
+
* copy of a secret that is kept once per node (`secretVariant`).
|
|
56
60
|
*/
|
|
57
|
-
export type SecretsProvider = (
|
|
61
|
+
export type SecretsProvider = (
|
|
62
|
+
name: SecretName,
|
|
63
|
+
node?: string
|
|
64
|
+
) => Promise<string | null>;
|
|
58
65
|
|
|
59
66
|
const present = (value: string | null | undefined): string | null =>
|
|
60
67
|
value?.trim() ? value.trim() : null;
|
|
61
68
|
|
|
62
69
|
/** `proxy-client-key` is read from `DEVFLEET_SECRET_PROXY_CLIENT_KEY`. */
|
|
63
|
-
export const envName = (name: SecretName): string =>
|
|
64
|
-
`DEVFLEET_SECRET_${name.toUpperCase().replaceAll('-', '_')}`;
|
|
70
|
+
export const envName = (name: SecretName, node?: string): string =>
|
|
71
|
+
`DEVFLEET_SECRET_${secretVariant(name, node).toUpperCase().replaceAll('-', '_')}`;
|
|
72
|
+
|
|
73
|
+
/** The name of a node's own copy of a secret: `github-token` of `worker-one`
|
|
74
|
+
* is `github-token-worker-one` (`DEVFLEET_SECRET_GITHUB_TOKEN_WORKER_ONE`, the
|
|
75
|
+
* file or sops key `github-token-worker-one`, the Vault field `FIELD_WORKER_ONE`).
|
|
76
|
+
*/
|
|
77
|
+
export const secretVariant = (name: SecretName, node?: string): string =>
|
|
78
|
+
node ? `${name}-${node}` : name;
|
|
79
|
+
|
|
80
|
+
/** Where a Vault fleet keeps a secret. The GitHub token was always read from
|
|
81
|
+
* the field `GITHUB_TOKEN` beside the proxy client key; a fleet that names no
|
|
82
|
+
* location of its own keeps it there.
|
|
83
|
+
*/
|
|
84
|
+
export function vaultLocation(
|
|
85
|
+
config: Extract<SecretsConfig, { provider: 'vault' }>,
|
|
86
|
+
name: SecretName,
|
|
87
|
+
node?: string
|
|
88
|
+
): VaultLocation | null {
|
|
89
|
+
const location =
|
|
90
|
+
config.locations[name as keyof typeof config.locations] ??
|
|
91
|
+
(name === 'github-token'
|
|
92
|
+
? {
|
|
93
|
+
path: config.locations['proxy-client-key'].path,
|
|
94
|
+
field: 'GITHUB_TOKEN',
|
|
95
|
+
}
|
|
96
|
+
: null);
|
|
97
|
+
if (!location) return null;
|
|
98
|
+
return node
|
|
99
|
+
? {
|
|
100
|
+
path: location.path,
|
|
101
|
+
field: `${location.field}_${node.toUpperCase().replaceAll('-', '_')}`,
|
|
102
|
+
}
|
|
103
|
+
: location;
|
|
104
|
+
}
|
|
65
105
|
|
|
66
106
|
export function secretsProvider(
|
|
67
107
|
config: SecretsConfig,
|
|
@@ -74,29 +114,34 @@ export function secretsProvider(
|
|
|
74
114
|
const env = options.env ?? process.env;
|
|
75
115
|
const run = options.run ?? execute;
|
|
76
116
|
if (config.provider === 'env')
|
|
77
|
-
return async (name) => present(env[envName(name)]);
|
|
117
|
+
return async (name, node) => present(env[envName(name, node)]);
|
|
78
118
|
if (config.provider === 'file') {
|
|
79
119
|
// One private file per secret, named after it.
|
|
80
120
|
const directory =
|
|
81
121
|
config.directory ??
|
|
82
122
|
join(options.home ?? homedir(), '.config/dev-fleet/secrets');
|
|
83
|
-
return async (name) =>
|
|
84
|
-
present(
|
|
123
|
+
return async (name, node) =>
|
|
124
|
+
present(
|
|
125
|
+
await readFile(
|
|
126
|
+
join(directory, secretVariant(name, node)),
|
|
127
|
+
'utf8'
|
|
128
|
+
).catch(() => null)
|
|
129
|
+
);
|
|
85
130
|
}
|
|
86
131
|
if (config.provider === 'sops')
|
|
87
132
|
// An age- (or KMS-) encrypted document with one top-level key per secret.
|
|
88
|
-
return async (name) =>
|
|
133
|
+
return async (name, node) =>
|
|
89
134
|
present(
|
|
90
135
|
await run([
|
|
91
136
|
'sops',
|
|
92
137
|
'--decrypt',
|
|
93
138
|
'--extract',
|
|
94
|
-
`["${name}"]`,
|
|
139
|
+
`["${secretVariant(name, node)}"]`,
|
|
95
140
|
config.file,
|
|
96
141
|
])
|
|
97
142
|
);
|
|
98
|
-
return async (name) => {
|
|
99
|
-
const location = config
|
|
143
|
+
return async (name, node) => {
|
|
144
|
+
const location = vaultLocation(config, name, node);
|
|
100
145
|
if (!location) return null;
|
|
101
146
|
return present(
|
|
102
147
|
await run([
|
package/src/service-auth.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { record } from './config';
|
|
2
|
+
import { copilotCheck } from './github/copilot';
|
|
2
3
|
import type { Check, CommandResult, Runner } from './types';
|
|
3
4
|
|
|
4
5
|
interface Service {
|
|
@@ -48,8 +49,6 @@ export async function codingAuth(
|
|
|
48
49
|
export const PROXY_ROUTED: Record<string, string> = {
|
|
49
50
|
gemini:
|
|
50
51
|
'gemini signs in through the CLI proxy and has no T3 launcher; provider:gemini:inference proves its route from this node.',
|
|
51
|
-
copilot:
|
|
52
|
-
'copilot is not routed through the CLI proxy and dev-fleet does not manage its GitHub sign-in; no provider account is checked.',
|
|
53
52
|
opencode:
|
|
54
53
|
'opencode signs in through the CLI proxy; agent:muse:session and the per-account agent:opencode sessions prove it from this node.',
|
|
55
54
|
};
|
|
@@ -101,6 +100,7 @@ export async function serviceAuth(run: Runner): Promise<Check[]> {
|
|
|
101
100
|
];
|
|
102
101
|
const checks: Check[] = [];
|
|
103
102
|
for (const service of services) checks.push(await serviceCheck(service, run));
|
|
103
|
+
checks.push(await copilotCheck(run));
|
|
104
104
|
for (const [id, reason] of Object.entries(PROXY_ROUTED))
|
|
105
105
|
checks.push({
|
|
106
106
|
id: `auth:${id}`,
|
package/src/state/check.ts
CHANGED
|
@@ -49,6 +49,13 @@ const FINDINGS: Record<
|
|
|
49
49
|
action:
|
|
50
50
|
'Check the network, the bucket named under "state" in fleet.json (or saved in bucket.json) and its credentials: DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY for this command, or the ones devfleet fleet-state --init saved on this machine. Then run devfleet fleet-state --push, which also installs the pinned Litestream when it is missing (that needs mise, which the first fleet-sync on a machine installs). Details are withheld to protect credentials.',
|
|
51
51
|
},
|
|
52
|
+
corrupt: {
|
|
53
|
+
status: 'fail',
|
|
54
|
+
summary:
|
|
55
|
+
'The bucket answered, but the fleet state it holds cannot be read: the replica is damaged.',
|
|
56
|
+
action:
|
|
57
|
+
'The network and the credentials work; the latest version in the bucket does not. Restore the last good version from before the damage, which also uploads it as the current one: devfleet fleet-state --restore --timestamp 2026-11-01T00:00:00Z --yes (a time before the damage; Litestream keeps thirty days). See docs/state.md.',
|
|
58
|
+
},
|
|
52
59
|
diverged: {
|
|
53
60
|
status: 'fail',
|
|
54
61
|
summary: 'This machine and the bucket hold different fleet states.',
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/** The machine's own identifier, which survives a reinstall of dev-fleet
|
|
6
|
+
* and differs between two Macs given the same host name.
|
|
7
|
+
*/
|
|
8
|
+
function machineId(platform: NodeJS.Platform): string | null {
|
|
9
|
+
try {
|
|
10
|
+
if (platform === 'linux')
|
|
11
|
+
return readFileSync('/etc/machine-id', 'utf8').trim() || null;
|
|
12
|
+
if (platform === 'darwin') {
|
|
13
|
+
const ioreg = spawnSync(
|
|
14
|
+
'/usr/sbin/ioreg',
|
|
15
|
+
['-rd1', '-c', 'IOPlatformExpertDevice'],
|
|
16
|
+
{ encoding: 'utf8', timeout: 5_000 }
|
|
17
|
+
);
|
|
18
|
+
return (
|
|
19
|
+
/"IOPlatformUUID" = "([^"]+)"/.exec(ioreg.stdout ?? '')?.[1] ?? null
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// An unidentified machine neither checks nor takes the lease.
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const known = new Map<string, string | null>();
|
|
29
|
+
|
|
30
|
+
/** This machine, as the manager lease names it: a digest of its machine ID,
|
|
31
|
+
* never the ID itself. `DEVFLEET_MANAGER_HOLDER` names it instead (a test, a
|
|
32
|
+
* container without a machine ID). Null when it cannot be told apart.
|
|
33
|
+
*/
|
|
34
|
+
export function managerHolder(
|
|
35
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
36
|
+
platform: NodeJS.Platform = process.platform
|
|
37
|
+
): string | null {
|
|
38
|
+
const named = env['DEVFLEET_MANAGER_HOLDER'];
|
|
39
|
+
if (named) return named;
|
|
40
|
+
if (!known.has(platform)) {
|
|
41
|
+
const id = machineId(platform);
|
|
42
|
+
known.set(
|
|
43
|
+
platform,
|
|
44
|
+
id
|
|
45
|
+
? `machine-${createHash('sha256').update(id).digest('hex').slice(0, 16)}`
|
|
46
|
+
: null
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return known.get(platform) ?? null;
|
|
50
|
+
}
|
package/src/state/litestream.ts
CHANGED
|
@@ -125,15 +125,18 @@ export async function replicate(target: Replication): Promise<boolean> {
|
|
|
125
125
|
]);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
/** Downloads the bucket's latest version to `output`, which must not exist
|
|
129
|
-
* `absent` means the bucket is
|
|
128
|
+
/** Downloads the bucket's latest version to `output`, which must not exist,
|
|
129
|
+
* or the version it held at `timestamp`. `absent` means the bucket is
|
|
130
|
+
* reachable and holds no state yet.
|
|
130
131
|
*/
|
|
131
132
|
export async function restore(
|
|
132
133
|
target: Replication,
|
|
133
|
-
output: string
|
|
134
|
+
output: string,
|
|
135
|
+
timestamp?: string
|
|
134
136
|
): Promise<'restored' | 'absent' | 'failed'> {
|
|
135
137
|
const ran = await invoke(target, 'restore', [
|
|
136
138
|
'-if-replica-exists',
|
|
139
|
+
...(timestamp ? ['-timestamp', timestamp] : []),
|
|
137
140
|
'-o',
|
|
138
141
|
output,
|
|
139
142
|
target.database,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- The fleet state as it was before it carried a schema version: the
|
|
2
|
+
-- documents and the head. A database without a version has exactly these.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
4
|
+
name TEXT PRIMARY KEY,
|
|
5
|
+
body TEXT NOT NULL,
|
|
6
|
+
updated_at TEXT NOT NULL
|
|
7
|
+
) STRICT;
|
|
8
|
+
CREATE TABLE IF NOT EXISTS head (
|
|
9
|
+
one INTEGER PRIMARY KEY CHECK (one = 1),
|
|
10
|
+
id TEXT NOT NULL,
|
|
11
|
+
sequence INTEGER NOT NULL,
|
|
12
|
+
written_at TEXT NOT NULL
|
|
13
|
+
) STRICT;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- The manager lease: which machine may change the fleet state, and until
|
|
2
|
+
-- when. It travels with the state, so the bucket says who holds it.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS lease (
|
|
4
|
+
one INTEGER PRIMARY KEY CHECK (one = 1),
|
|
5
|
+
holder TEXT NOT NULL,
|
|
6
|
+
expires_at TEXT NOT NULL
|
|
7
|
+
) STRICT;
|