@crvouga/dev-fleet 0.2.0 → 0.4.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 +50 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/manager.yml +3 -0
- package/ansible/macos/policy.json +11 -1
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/compose.yml +10 -0
- package/ansible/monitoring/container-watchdog.py +118 -0
- package/ansible/monitoring/hub-darwin.yml +146 -0
- package/ansible/net/remote-probe.sh +5 -0
- package/ansible/ubuntu/baseline.yml +2 -0
- package/ansible/ubuntu/disk-gc.py +25 -3
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +51 -1
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +4 -4
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +30 -2
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/authentication.md +90 -0
- package/docs/declared.md +5 -1
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +26 -15
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/topology.md +2 -0
- package/docs/ubuntu-sync.md +35 -28
- package/examples/fleet.json +19 -1
- package/llms.txt +3 -0
- package/mise.toml +10 -1
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +244 -1
- package/schema/output/check.schema.json +23 -0
- package/schema/output/error.schema.json +14 -0
- package/schema/output/fleet-report.schema.json +79 -0
- package/schema/output/fleet-sync.schema.json +112 -0
- package/schema/output/report.schema.json +38 -0
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +128 -2
- package/src/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-manager.ts +31 -4
- package/src/cli/fleet-sync-output.ts +3 -2
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +23 -14
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +126 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +86 -0
- package/src/cli/registry.ts +26 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +226 -7
- package/src/git-identity.ts +160 -0
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +13 -17
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +44 -12
- package/src/notify.ts +239 -0
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +87 -25
- package/src/remote-entry.ts +15 -1
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/service-auth.ts +15 -5
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- package/src/t3/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-linked-check.ts +1 -1
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/transport.ts +3 -2
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +68 -13
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
package/src/definition.ts
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
parseWorkerPolicy,
|
|
3
|
+
toolProfiles,
|
|
4
|
+
type WorkerPolicy,
|
|
5
|
+
} from './worker-policy';
|
|
6
|
+
import type { ToolProfile } from '../bootstrap/worker-tools';
|
|
2
7
|
import { readFileSync } from 'node:fs';
|
|
3
8
|
import { FleetUsageError } from './errors';
|
|
4
9
|
import { parseConfig, record } from './config';
|
|
5
10
|
import type { FleetConfig } from './types';
|
|
6
11
|
import { parseSecretsConfig, type SecretsConfig } from './secrets';
|
|
7
12
|
import { parseLocation, type BucketLocation } from './state/bucket';
|
|
13
|
+
import {
|
|
14
|
+
parseIdes,
|
|
15
|
+
parseT3,
|
|
16
|
+
type IdeId,
|
|
17
|
+
type T3Definition,
|
|
18
|
+
} from './ide/definition';
|
|
19
|
+
import { parseNotify, type NotifyConfig } from './notify';
|
|
8
20
|
|
|
9
21
|
export interface MachineDefinition {
|
|
10
22
|
id: string;
|
|
11
23
|
ssh: string;
|
|
12
24
|
os: 'linux' | 'macOS' | 'windows';
|
|
25
|
+
/** Tool profiles this worker adds to the fleet-wide ones. */
|
|
26
|
+
toolProfiles?: ToolProfile[];
|
|
13
27
|
network?: {
|
|
14
28
|
tailscaleName: string;
|
|
15
29
|
user: string;
|
|
@@ -40,7 +54,7 @@ export interface FleetDefinition {
|
|
|
40
54
|
workers: MachineDefinition[];
|
|
41
55
|
services: {
|
|
42
56
|
/** Public HTTPS origin of the CLI proxy every node uses. */
|
|
43
|
-
proxy: { endpoint: string };
|
|
57
|
+
proxy: { endpoint: string; accounts?: AccountPolicy };
|
|
44
58
|
monitoring: {
|
|
45
59
|
hubPort: 8443;
|
|
46
60
|
/** Public dashboard origin. */
|
|
@@ -49,11 +63,62 @@ export interface FleetDefinition {
|
|
|
49
63
|
managerAccount: string;
|
|
50
64
|
/** Read-only Beszel account visitors are signed in as. */
|
|
51
65
|
viewerAccount: string;
|
|
66
|
+
/** Scheduled off-machine backups fleet-sync sets on the hub. */
|
|
67
|
+
backups?: HubBackups;
|
|
52
68
|
};
|
|
53
69
|
};
|
|
54
70
|
worker?: WorkerPolicy;
|
|
71
|
+
/** The IDEs every machine runs; `["t3"]` when not named (idesOf). */
|
|
72
|
+
ides?: IdeId[];
|
|
73
|
+
/** What every machine's T3 holds beyond its provider instances. */
|
|
74
|
+
t3?: T3Definition;
|
|
75
|
+
/** The Git identity and GitHub credential helper every node, manager
|
|
76
|
+
* included, commits and pushes with.
|
|
77
|
+
*/
|
|
78
|
+
git?: GitIdentity;
|
|
79
|
+
/** Where fleet-watch sends notifications; absent, nobody is notified. */
|
|
80
|
+
notify?: NotifyConfig;
|
|
55
81
|
ubuntu: { release: '26.04'; architectures: ['amd64', 'arm64'] };
|
|
56
82
|
}
|
|
83
|
+
/** How provider accounts are watched and chosen (src/accounts/). */
|
|
84
|
+
export interface AccountPolicy {
|
|
85
|
+
/** Fewer usable accounts of a provider than this is a warning; none fails. */
|
|
86
|
+
minimumUsable?: number;
|
|
87
|
+
/** Per provider (`claude`, `codex`, `antigravity`): which account an
|
|
88
|
+
* unpinned request goes to first. Accounts cooling down are skipped.
|
|
89
|
+
*/
|
|
90
|
+
selection?: Record<string, AccountSelection>;
|
|
91
|
+
}
|
|
92
|
+
export interface AccountSelection {
|
|
93
|
+
strategy: 'fill-first' | 'round-robin' | 'least-recently-limited';
|
|
94
|
+
/** Account labels (sign-in emails) first, in this order; the others
|
|
95
|
+
* follow in the order of their routing prefix.
|
|
96
|
+
*/
|
|
97
|
+
order?: string[];
|
|
98
|
+
}
|
|
99
|
+
export interface HubBackups {
|
|
100
|
+
/** Cron expression of the hub's backup schedule. */
|
|
101
|
+
schedule: string;
|
|
102
|
+
/** How many scheduled backups the hub keeps. */
|
|
103
|
+
keep: number;
|
|
104
|
+
/** S3-compatible storage off the manager; its keys are secrets. */
|
|
105
|
+
storage: {
|
|
106
|
+
bucket: string;
|
|
107
|
+
endpoint: string;
|
|
108
|
+
region: string;
|
|
109
|
+
forcePathStyle?: boolean;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export interface GitIdentity {
|
|
113
|
+
/** `user.name` of every commit. */
|
|
114
|
+
name: string;
|
|
115
|
+
/** `user.email` of every commit. */
|
|
116
|
+
email: string;
|
|
117
|
+
/** A repository each node must reach through git with its own
|
|
118
|
+
* credentials, proving it can push (`worker:git-push-auth`).
|
|
119
|
+
*/
|
|
120
|
+
verifyRepository?: string;
|
|
121
|
+
}
|
|
57
122
|
const ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
58
123
|
const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
|
|
59
124
|
function fail(): never {
|
|
@@ -90,7 +155,10 @@ function validPort(value: unknown): boolean {
|
|
|
90
155
|
);
|
|
91
156
|
}
|
|
92
157
|
function worker(value: unknown): MachineDefinition {
|
|
93
|
-
if (
|
|
158
|
+
if (
|
|
159
|
+
!record(value) ||
|
|
160
|
+
!keys(value, ['id', 'ssh', 'os', 'network', 'toolProfiles'])
|
|
161
|
+
)
|
|
94
162
|
return fail();
|
|
95
163
|
if (typeof value['id'] !== 'string' || !ID.test(value['id'])) return fail();
|
|
96
164
|
if (typeof value['ssh'] !== 'string' || !HOST.test(value['ssh']))
|
|
@@ -102,6 +170,9 @@ function worker(value: unknown): MachineDefinition {
|
|
|
102
170
|
ssh: value['ssh'],
|
|
103
171
|
os: 'linux',
|
|
104
172
|
...(value['network'] ? { network: network(value['network']) } : {}),
|
|
173
|
+
...(value['toolProfiles'] === undefined
|
|
174
|
+
? {}
|
|
175
|
+
: { toolProfiles: toolProfiles(value['toolProfiles']) ?? fail() }),
|
|
105
176
|
};
|
|
106
177
|
}
|
|
107
178
|
function manager(value: unknown): ManagerDefinition {
|
|
@@ -148,6 +219,10 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
148
219
|
'services',
|
|
149
220
|
'ubuntu',
|
|
150
221
|
'worker',
|
|
222
|
+
'ides',
|
|
223
|
+
't3',
|
|
224
|
+
'git',
|
|
225
|
+
'notify',
|
|
151
226
|
])
|
|
152
227
|
)
|
|
153
228
|
return fail();
|
|
@@ -166,9 +241,25 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
166
241
|
workers,
|
|
167
242
|
services: services(value['services']),
|
|
168
243
|
ubuntu: baseline(value['ubuntu']),
|
|
244
|
+
...optionalSections(value),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** The sections a fleet may leave out, kept only when named: an earlier
|
|
248
|
+
* revision must still read the copy.
|
|
249
|
+
*/
|
|
250
|
+
function optionalSections(
|
|
251
|
+
value: Record<string, unknown>
|
|
252
|
+
): Pick<FleetDefinition, 'worker' | 'ides' | 't3' | 'git' | 'notify'> {
|
|
253
|
+
return {
|
|
169
254
|
...(value['worker'] === undefined
|
|
170
255
|
? {}
|
|
171
256
|
: { worker: parseWorkerPolicy(value['worker']) }),
|
|
257
|
+
...(value['ides'] === undefined ? {} : { ides: parseIdes(value['ides']) }),
|
|
258
|
+
...(value['t3'] === undefined ? {} : { t3: parseT3(value['t3']) }),
|
|
259
|
+
...(value['git'] === undefined ? {} : { git: gitIdentity(value['git']) }),
|
|
260
|
+
...(value['notify'] === undefined
|
|
261
|
+
? {}
|
|
262
|
+
: { notify: parseNotify(value['notify']) ?? fail() }),
|
|
172
263
|
};
|
|
173
264
|
}
|
|
174
265
|
/** Singleton services name no host: they always run on the manager. */
|
|
@@ -177,31 +268,159 @@ function services(value: unknown): FleetDefinition['services'] {
|
|
|
177
268
|
const { proxy, monitoring } = value;
|
|
178
269
|
if (
|
|
179
270
|
!record(proxy) ||
|
|
180
|
-
!keys(proxy, ['endpoint']) ||
|
|
271
|
+
!keys(proxy, ['endpoint', 'accounts']) ||
|
|
181
272
|
!origin(proxy['endpoint'])
|
|
182
273
|
)
|
|
183
274
|
return fail();
|
|
184
275
|
return {
|
|
185
|
-
proxy: {
|
|
276
|
+
proxy: {
|
|
277
|
+
endpoint: proxy['endpoint'],
|
|
278
|
+
...(proxy['accounts'] === undefined
|
|
279
|
+
? {}
|
|
280
|
+
: { accounts: accountPolicy(proxy['accounts']) }),
|
|
281
|
+
},
|
|
186
282
|
monitoring: monitoringService(monitoring),
|
|
187
283
|
};
|
|
188
284
|
}
|
|
285
|
+
const STRATEGIES = ['fill-first', 'round-robin', 'least-recently-limited'];
|
|
286
|
+
function accountPolicy(value: unknown): AccountPolicy {
|
|
287
|
+
if (!record(value) || !keys(value, ['minimumUsable', 'selection']))
|
|
288
|
+
return fail();
|
|
289
|
+
const { minimumUsable, selection } = value;
|
|
290
|
+
if (
|
|
291
|
+
minimumUsable !== undefined &&
|
|
292
|
+
!(Number.isInteger(minimumUsable) && Number(minimumUsable) >= 1)
|
|
293
|
+
)
|
|
294
|
+
return fail();
|
|
295
|
+
return {
|
|
296
|
+
...(minimumUsable === undefined
|
|
297
|
+
? {}
|
|
298
|
+
: { minimumUsable: minimumUsable as number }),
|
|
299
|
+
...(selection === undefined
|
|
300
|
+
? {}
|
|
301
|
+
: { selection: selectionPolicies(selection) }),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function selectionPolicies(value: unknown): Record<string, AccountSelection> {
|
|
305
|
+
if (!record(value)) return fail();
|
|
306
|
+
const providers: Record<string, AccountSelection> = {};
|
|
307
|
+
for (const [provider, entry] of Object.entries(value)) {
|
|
308
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(provider)) return fail();
|
|
309
|
+
providers[provider] = accountSelection(entry);
|
|
310
|
+
}
|
|
311
|
+
return providers;
|
|
312
|
+
}
|
|
313
|
+
function accountSelection(entry: unknown): AccountSelection {
|
|
314
|
+
if (
|
|
315
|
+
!record(entry) ||
|
|
316
|
+
!keys(entry, ['strategy', 'order']) ||
|
|
317
|
+
!STRATEGIES.includes(String(entry['strategy']))
|
|
318
|
+
)
|
|
319
|
+
return fail();
|
|
320
|
+
const { order } = entry;
|
|
321
|
+
if (
|
|
322
|
+
order !== undefined &&
|
|
323
|
+
!(Array.isArray(order) && order.every((label) => account(label)))
|
|
324
|
+
)
|
|
325
|
+
return fail();
|
|
326
|
+
return {
|
|
327
|
+
strategy: entry['strategy'] as AccountSelection['strategy'],
|
|
328
|
+
...(order ? { order: order as string[] } : {}),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
189
331
|
function monitoringService(
|
|
190
332
|
value: unknown
|
|
191
333
|
): FleetDefinition['services']['monitoring'] {
|
|
192
|
-
const fields = [
|
|
334
|
+
const fields = [
|
|
335
|
+
'hubPort',
|
|
336
|
+
'publicUrl',
|
|
337
|
+
'managerAccount',
|
|
338
|
+
'viewerAccount',
|
|
339
|
+
'backups',
|
|
340
|
+
];
|
|
193
341
|
if (!record(value) || !keys(value, fields) || value['hubPort'] !== 8443)
|
|
194
342
|
return fail();
|
|
195
343
|
const { publicUrl, managerAccount, viewerAccount } = value;
|
|
196
344
|
if (!origin(publicUrl) || !account(managerAccount) || !account(viewerAccount))
|
|
197
345
|
return fail();
|
|
198
|
-
return {
|
|
346
|
+
return {
|
|
347
|
+
hubPort: 8443,
|
|
348
|
+
publicUrl,
|
|
349
|
+
managerAccount,
|
|
350
|
+
viewerAccount,
|
|
351
|
+
...(value['backups'] === undefined
|
|
352
|
+
? {}
|
|
353
|
+
: { backups: hubBackups(value['backups']) }),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const text = (value: unknown, pattern: RegExp): value is string =>
|
|
357
|
+
typeof value === 'string' && pattern.test(value);
|
|
358
|
+
function hubBackups(value: unknown): HubBackups {
|
|
359
|
+
if (!record(value) || !keys(value, ['schedule', 'keep', 'storage']))
|
|
360
|
+
return fail();
|
|
361
|
+
const { schedule, keep } = value;
|
|
362
|
+
if (
|
|
363
|
+
!text(schedule, /^[0-9*/,-]+( [0-9*/,-]+){4}$/) ||
|
|
364
|
+
!Number.isInteger(keep) ||
|
|
365
|
+
Number(keep) < 1 ||
|
|
366
|
+
Number(keep) > 365
|
|
367
|
+
)
|
|
368
|
+
return fail();
|
|
369
|
+
return {
|
|
370
|
+
schedule,
|
|
371
|
+
keep: Number(keep),
|
|
372
|
+
storage: backupStorage(value['storage']),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function backupStorage(value: unknown): HubBackups['storage'] {
|
|
376
|
+
if (
|
|
377
|
+
!record(value) ||
|
|
378
|
+
!keys(value, ['bucket', 'endpoint', 'region', 'forcePathStyle'])
|
|
379
|
+
)
|
|
380
|
+
return fail();
|
|
381
|
+
const { bucket, endpoint, region, forcePathStyle } = value;
|
|
382
|
+
if (
|
|
383
|
+
!text(bucket, /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/) ||
|
|
384
|
+
!text(endpoint, /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/) ||
|
|
385
|
+
!text(region, /^[a-z0-9-]{1,32}$/) ||
|
|
386
|
+
(forcePathStyle !== undefined && typeof forcePathStyle !== 'boolean')
|
|
387
|
+
)
|
|
388
|
+
return fail();
|
|
389
|
+
return {
|
|
390
|
+
bucket,
|
|
391
|
+
endpoint,
|
|
392
|
+
region,
|
|
393
|
+
...(forcePathStyle === undefined ? {} : { forcePathStyle }),
|
|
394
|
+
};
|
|
199
395
|
}
|
|
200
396
|
const origin = (value: unknown): value is string =>
|
|
201
397
|
typeof value === 'string' && /^https:\/\/[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
202
398
|
const account = (value: unknown): value is string =>
|
|
203
399
|
typeof value === 'string' &&
|
|
204
400
|
/^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
401
|
+
function gitIdentity(value: unknown): GitIdentity {
|
|
402
|
+
if (!record(value) || !keys(value, ['name', 'email', 'verifyRepository']))
|
|
403
|
+
return fail();
|
|
404
|
+
const { name, email, verifyRepository } = value;
|
|
405
|
+
// Written into a quoted Git configuration value: no quote, backslash or
|
|
406
|
+
// line break.
|
|
407
|
+
if (typeof name !== 'string' || !/^[^"\\\n\r]{1,100}$/.test(name))
|
|
408
|
+
return fail();
|
|
409
|
+
if (!account(email)) return fail();
|
|
410
|
+
if (
|
|
411
|
+
verifyRepository !== undefined &&
|
|
412
|
+
(typeof verifyRepository !== 'string' ||
|
|
413
|
+
!/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(
|
|
414
|
+
verifyRepository
|
|
415
|
+
))
|
|
416
|
+
)
|
|
417
|
+
return fail();
|
|
418
|
+
return {
|
|
419
|
+
name,
|
|
420
|
+
email,
|
|
421
|
+
...(verifyRepository === undefined ? {} : { verifyRepository }),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
205
424
|
function secrets(value: unknown): SecretsConfig {
|
|
206
425
|
return parseSecretsConfig(value) ?? fail();
|
|
207
426
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { runAnsible } from './platform/ansible';
|
|
4
|
+
import type { GitIdentity } from './definition';
|
|
5
|
+
import type { Check, Runner } from './types';
|
|
6
|
+
|
|
7
|
+
/** `ansible/github/git.yml` writes the declared identity and the GitHub
|
|
8
|
+
* credential helper in the node's own Git configuration; the two checks
|
|
9
|
+
* below measure them afterwards. The same on the manager and every worker.
|
|
10
|
+
*/
|
|
11
|
+
export async function convergeGitIdentity(
|
|
12
|
+
root: string,
|
|
13
|
+
git: GitIdentity | undefined,
|
|
14
|
+
run: Runner,
|
|
15
|
+
home: string = homedir()
|
|
16
|
+
): Promise<Check[]> {
|
|
17
|
+
if (!git) return [];
|
|
18
|
+
const result = await runAnsible(
|
|
19
|
+
run,
|
|
20
|
+
resolve(root, 'ansible/github/git.yml'),
|
|
21
|
+
{
|
|
22
|
+
cwd: resolve(root, 'ansible/github'),
|
|
23
|
+
timeoutMs: 120_000,
|
|
24
|
+
input: JSON.stringify({
|
|
25
|
+
git_name: git.name,
|
|
26
|
+
git_email: git.email,
|
|
27
|
+
git_home: home,
|
|
28
|
+
}),
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
id: 'git:configuration',
|
|
34
|
+
status: result.ok ? 'pass' : 'fail',
|
|
35
|
+
summary: result.ok
|
|
36
|
+
? 'The declared Git identity and the GitHub credential helper are written in ~/.config/git/config.'
|
|
37
|
+
: 'GIT_CONFIGURATION_INCOMPLETE',
|
|
38
|
+
...(result.ok
|
|
39
|
+
? {}
|
|
40
|
+
: {
|
|
41
|
+
remedy: {
|
|
42
|
+
action:
|
|
43
|
+
'Inspect ~/.config/git/config and ~/.gitconfig on this node: a ~/.gitconfig that sets user.name, user.email or a GitHub credential helper wins over the fleet. Remove those lines and rerun fleet-sync.',
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Git with no prompt: a missing credential fails instead of waiting. */
|
|
51
|
+
const QUIET = {
|
|
52
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
53
|
+
GCM_INTERACTIVE: 'never',
|
|
54
|
+
GIT_ASKPASS: '',
|
|
55
|
+
SSH_ASKPASS: '',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function effective(run: Runner, key: string): Promise<string> {
|
|
59
|
+
const result = await run(['git', 'config', '--global', '--get-all', key], {
|
|
60
|
+
timeoutMs: 10_000,
|
|
61
|
+
});
|
|
62
|
+
return result.ok ? (result.stdout.trim().split('\n').pop() ?? '') : '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Commits carry the declared author: the effective `user.name` and
|
|
66
|
+
* `user.email` outside any repository are the fleet's.
|
|
67
|
+
*/
|
|
68
|
+
export async function gitIdentityCheck(
|
|
69
|
+
git: GitIdentity | undefined,
|
|
70
|
+
run: Runner
|
|
71
|
+
): Promise<Check> {
|
|
72
|
+
const id = 'worker:git-identity';
|
|
73
|
+
if (!git)
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
status: 'warn',
|
|
77
|
+
summary: 'GIT_IDENTITY_UNDECLARED',
|
|
78
|
+
remedy: {
|
|
79
|
+
action:
|
|
80
|
+
'Declare git.name and git.email in fleet.json and rerun fleet-sync; until then commits on this node carry whatever author it happens to have.',
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const name = await effective(run, 'user.name');
|
|
84
|
+
const email = await effective(run, 'user.email');
|
|
85
|
+
if (name === git.name && email === git.email)
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
status: 'pass',
|
|
89
|
+
summary: `Commits on this node are authored as ${git.name} <${git.email}>.`,
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
id,
|
|
93
|
+
status: 'fail',
|
|
94
|
+
summary: !name || !email ? 'GIT_IDENTITY_MISSING' : 'GIT_IDENTITY_MISMATCH',
|
|
95
|
+
remedy: {
|
|
96
|
+
action:
|
|
97
|
+
'Rerun fleet-sync: it writes the identity in ~/.config/git/config. A ~/.gitconfig that sets another user.name or user.email wins over it; remove those lines.',
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A push is authorized through git itself, never through gh: Git's
|
|
103
|
+
* credential helper must answer for github.com, and the declared repository
|
|
104
|
+
* must answer an authenticated listing. The credential is read and dropped;
|
|
105
|
+
* it is never kept, printed or reported.
|
|
106
|
+
*/
|
|
107
|
+
export async function gitPushAuthCheck(
|
|
108
|
+
git: GitIdentity | undefined,
|
|
109
|
+
run: Runner
|
|
110
|
+
): Promise<Check> {
|
|
111
|
+
const id = 'worker:git-push-auth';
|
|
112
|
+
const fill = await run(['git', 'credential', 'fill'], {
|
|
113
|
+
input: 'protocol=https\nhost=github.com\n\n',
|
|
114
|
+
env: QUIET,
|
|
115
|
+
timeoutMs: 20_000,
|
|
116
|
+
});
|
|
117
|
+
if (!fill.ok || !/^password=./m.test(fill.stdout))
|
|
118
|
+
return {
|
|
119
|
+
id,
|
|
120
|
+
status: 'fail',
|
|
121
|
+
summary: 'GIT_CREDENTIAL_HELPER_MISSING',
|
|
122
|
+
remedy: {
|
|
123
|
+
action:
|
|
124
|
+
'Git has no credential for github.com, so an agent cannot push over HTTPS. Rerun fleet-sync, which declares gh as the credential helper; if gh is signed out, the worker signs in again from its own Vault session (auth:github).',
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
if (git?.verifyRepository) {
|
|
128
|
+
const listed = await run(
|
|
129
|
+
['git', 'ls-remote', '--heads', git.verifyRepository],
|
|
130
|
+
{ env: QUIET, timeoutMs: 30_000 }
|
|
131
|
+
);
|
|
132
|
+
if (!listed.ok)
|
|
133
|
+
return {
|
|
134
|
+
id,
|
|
135
|
+
status: 'fail',
|
|
136
|
+
summary:
|
|
137
|
+
listed.reason === 'timeout'
|
|
138
|
+
? 'GIT_REMOTE_UNREACHABLE'
|
|
139
|
+
: 'GIT_REMOTE_REFUSED',
|
|
140
|
+
remedy: {
|
|
141
|
+
action:
|
|
142
|
+
"The declared repository (git.verifyRepository) did not answer git with this node's credentials. Check that the fleet's GitHub account can push to it and that github.com is reachable.",
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
id,
|
|
148
|
+
status: 'pass',
|
|
149
|
+
summary: git?.verifyRepository
|
|
150
|
+
? "Git's credential helper answers for github.com and the declared repository answers through git."
|
|
151
|
+
: "Git's credential helper answers for github.com; declare git.verifyRepository to prove a repository too.",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function gitChecks(
|
|
156
|
+
git: GitIdentity | undefined,
|
|
157
|
+
run: Runner
|
|
158
|
+
): Promise<Check[]> {
|
|
159
|
+
return [await gitIdentityCheck(git, run), await gitPushAuthCheck(git, run)];
|
|
160
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { FleetUsageError } from '../errors';
|
|
2
|
+
import { record } from '../config';
|
|
3
|
+
|
|
4
|
+
/** The IDEs dev-fleet supports. The support matrix in the README lists the
|
|
5
|
+
* same; enabling one on the fleet is `ides` in fleet.json, not new code.
|
|
6
|
+
*/
|
|
7
|
+
export const SUPPORTED_IDES = ['t3'] as const;
|
|
8
|
+
export type IdeId = (typeof SUPPORTED_IDES)[number];
|
|
9
|
+
|
|
10
|
+
/** What `t3` in fleet.json declares for every machine's T3. */
|
|
11
|
+
export interface T3Definition {
|
|
12
|
+
/** Top-level T3 settings every machine holds the same: tool servers,
|
|
13
|
+
* projects, selected preferences. A key not named here stays local.
|
|
14
|
+
*/
|
|
15
|
+
profile?: Record<string, unknown>;
|
|
16
|
+
/** Models T3 must offer for every account of a tool, by tool. */
|
|
17
|
+
requiredModels?: Record<string, string[]>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const KEY = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
21
|
+
/** Settings another part of dev-fleet owns. */
|
|
22
|
+
const RESERVED = ['providerInstances'];
|
|
23
|
+
|
|
24
|
+
function invalid(detail: string): never {
|
|
25
|
+
throw new FleetUsageError(`Invalid fleet.json: ${detail}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The IDEs a fleet runs: those it names, or T3 alone. */
|
|
29
|
+
export const idesOf = (definition: { ides?: readonly IdeId[] }): IdeId[] => [
|
|
30
|
+
...(definition.ides ?? ['t3']),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export function parseIdes(value: unknown): IdeId[] {
|
|
34
|
+
if (
|
|
35
|
+
!Array.isArray(value) ||
|
|
36
|
+
value.length === 0 ||
|
|
37
|
+
new Set(value).size !== value.length ||
|
|
38
|
+
value.some((id) => !SUPPORTED_IDES.includes(id as IdeId))
|
|
39
|
+
)
|
|
40
|
+
invalid(`ides lists each of ${SUPPORTED_IDES.join(', ')} at most once.`);
|
|
41
|
+
return value as IdeId[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseT3(value: unknown): T3Definition {
|
|
45
|
+
if (
|
|
46
|
+
!record(value) ||
|
|
47
|
+
Object.keys(value).some(
|
|
48
|
+
(key) => !['profile', 'requiredModels'].includes(key)
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
invalid('t3 holds only profile and requiredModels.');
|
|
52
|
+
const result: T3Definition = {};
|
|
53
|
+
if (value['profile'] !== undefined) {
|
|
54
|
+
const profile = value['profile'];
|
|
55
|
+
if (
|
|
56
|
+
!record(profile) ||
|
|
57
|
+
Object.keys(profile).some(
|
|
58
|
+
(key) => !KEY.test(key) || RESERVED.includes(key)
|
|
59
|
+
) ||
|
|
60
|
+
JSON.stringify(profile).length > 65_536
|
|
61
|
+
)
|
|
62
|
+
invalid(
|
|
63
|
+
't3.profile names top-level T3 settings (not providerInstances) and stays under 64 KiB.'
|
|
64
|
+
);
|
|
65
|
+
result.profile = profile;
|
|
66
|
+
}
|
|
67
|
+
if (value['requiredModels'] !== undefined) {
|
|
68
|
+
const required = value['requiredModels'];
|
|
69
|
+
if (
|
|
70
|
+
!record(required) ||
|
|
71
|
+
Object.entries(required).some(
|
|
72
|
+
([tool, models]) =>
|
|
73
|
+
!KEY.test(tool) ||
|
|
74
|
+
!Array.isArray(models) ||
|
|
75
|
+
models.some(
|
|
76
|
+
(model) =>
|
|
77
|
+
typeof model !== 'string' ||
|
|
78
|
+
!/^[A-Za-z0-9._:-]{1,128}$/.test(model)
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
invalid('t3.requiredModels lists model names by tool.');
|
|
83
|
+
result.requiredModels = required as Record<string, string[]>;
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
package/src/ide/ide.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Check, FleetNode, Runner } from '../types';
|
|
2
|
+
|
|
3
|
+
/** What every supported IDE is asked for. An implementation owns its own
|
|
4
|
+
* install, service and policy (an Ansible role per IDE, with the common
|
|
5
|
+
* variable contract: version, checksum, policy, instances, profile) and is
|
|
6
|
+
* reached only through these calls, so the orchestration never names one.
|
|
7
|
+
*/
|
|
8
|
+
export interface Ide {
|
|
9
|
+
/** The name the fleet configuration enables it by, under `ides`. */
|
|
10
|
+
readonly id: string;
|
|
11
|
+
/** The IDE's settings on this machine: provider instances from the account
|
|
12
|
+
* roster, the declared profile and the models it offers. Run on the manager
|
|
13
|
+
* and on every worker by the machine itself.
|
|
14
|
+
*/
|
|
15
|
+
settings(context: MachineContext): Promise<Check[]>;
|
|
16
|
+
/** The manager's own IDE: its settings, its pinned version and its policy. */
|
|
17
|
+
manager(context: MachineContext): Promise<Check[]>;
|
|
18
|
+
/** What the manager measures of a worker's IDE: remote access, pairing and
|
|
19
|
+
* the version the worker runs.
|
|
20
|
+
*/
|
|
21
|
+
worker(node: FleetNode, context: WorkerContext): Promise<Check[]>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MachineContext {
|
|
25
|
+
/** The node ID this machine is in the fleet. */
|
|
26
|
+
id: string;
|
|
27
|
+
/** The checkout this run applies. */
|
|
28
|
+
workspace: string;
|
|
29
|
+
apply: boolean;
|
|
30
|
+
run: Runner;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface WorkerContext {
|
|
34
|
+
run: Runner;
|
|
35
|
+
/** The manager's checkout of the revision this sync applies. */
|
|
36
|
+
workspace: string;
|
|
37
|
+
/** The checks this sync already reported for the worker. */
|
|
38
|
+
earlier: Check[];
|
|
39
|
+
/** Records what identifies the worker's IDE in the fleet state, the first
|
|
40
|
+
* time it is seen through a verified link.
|
|
41
|
+
*/
|
|
42
|
+
remember?: (node: string, identity: string) => Promise<void>;
|
|
43
|
+
}
|
package/src/ide/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Check, FleetNode } from '../types';
|
|
2
|
+
import type { Ide, MachineContext, WorkerContext } from './ide';
|
|
3
|
+
|
|
4
|
+
/** The implementations the fleet enables, in the order it names them. An ID
|
|
5
|
+
* nobody implements is refused before anything runs.
|
|
6
|
+
*/
|
|
7
|
+
export function enabled(
|
|
8
|
+
ids: readonly string[],
|
|
9
|
+
registry: Readonly<Record<string, Ide>>
|
|
10
|
+
): Ide[] {
|
|
11
|
+
return ids.map((id) => {
|
|
12
|
+
const ide = registry[id];
|
|
13
|
+
if (!ide) throw new Error(`Unsupported IDE: ${id}`);
|
|
14
|
+
return ide;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function settingsChecks(
|
|
19
|
+
ides: Ide[],
|
|
20
|
+
context: MachineContext
|
|
21
|
+
): Promise<Check[]> {
|
|
22
|
+
const checks: Check[] = [];
|
|
23
|
+
for (const ide of ides) checks.push(...(await ide.settings(context)));
|
|
24
|
+
return checks;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function managerChecks(
|
|
28
|
+
ides: Ide[],
|
|
29
|
+
context: MachineContext
|
|
30
|
+
): Promise<Check[]> {
|
|
31
|
+
const checks: Check[] = [];
|
|
32
|
+
for (const ide of ides) checks.push(...(await ide.manager(context)));
|
|
33
|
+
return checks;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function workerChecks(
|
|
37
|
+
ides: Ide[],
|
|
38
|
+
node: FleetNode,
|
|
39
|
+
context: WorkerContext
|
|
40
|
+
): Promise<Check[]> {
|
|
41
|
+
const checks: Check[] = [];
|
|
42
|
+
for (const ide of ides)
|
|
43
|
+
checks.push(
|
|
44
|
+
...(await ide.worker(node, {
|
|
45
|
+
...context,
|
|
46
|
+
earlier: [...context.earlier, ...checks],
|
|
47
|
+
}))
|
|
48
|
+
);
|
|
49
|
+
return checks;
|
|
50
|
+
}
|