@crvouga/dev-fleet 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -9
- package/agents/audit.md +18 -18
- package/ansible/agents/agents.yml +134 -0
- package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
- package/ansible/agents/templates/claude.settings.json.j2 +3 -0
- package/ansible/agents/templates/codex.config.toml.j2 +11 -0
- package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
- package/ansible/agents/templates/grok.config.toml.j2 +19 -0
- package/ansible/agents/templates/opencode.json.j2 +24 -0
- package/ansible/agents/templates/receipt.json.j2 +10 -0
- package/ansible/ansible.cfg +14 -0
- package/ansible/github/git.yml +82 -0
- package/ansible/macos/manager-privilege.yml +55 -9
- package/ansible/macos/manager.yml +154 -7
- package/ansible/macos/policy.json +13 -0
- package/ansible/macos/t3-policy.yml +5 -3
- package/ansible/macos/vault-renew.yml +85 -0
- package/ansible/monitoring/agent-darwin.yml +3 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/monitoring/hub-darwin.yml +3 -0
- package/ansible/monitoring/hub-route-darwin.yml +1 -0
- package/ansible/net/remote-probe.sh +4 -1
- package/ansible/platforms.json +2 -2
- package/ansible/pyproject.toml +5 -0
- package/ansible/ubuntu/baseline.yml +39 -13
- package/ansible/ubuntu/disk-gc.py +6 -2
- package/ansible/ubuntu/firewall.nft.j2 +50 -0
- package/ansible/ubuntu/policy.json +125 -6
- package/ansible/ubuntu/reboot.yml +89 -0
- package/ansible/ubuntu/recovery.yml +13 -3
- package/ansible/ubuntu/t3/policy.json +3 -0
- package/ansible/ubuntu/t3-desktop.yml +23 -2
- package/ansible/ubuntu/t3-headless.yml +128 -0
- package/ansible/ubuntu/t3-health.sh +24 -10
- package/ansible/ubuntu/t3-idle.py +37 -2
- package/ansible/ubuntu/t3-policy.yml +4 -1
- package/ansible/ubuntu/t3-route.yml +1 -0
- package/ansible/ubuntu/tailscale.yml +54 -8
- package/ansible/ubuntu/tasks/browser.yml +3 -0
- package/ansible/ubuntu/tasks/containers-off.yml +43 -0
- package/ansible/ubuntu/tasks/docker.yml +53 -6
- package/ansible/ubuntu/tasks/dpkg.yml +4 -0
- package/ansible/ubuntu/tasks/features.yml +22 -0
- package/ansible/ubuntu/tasks/firewall.yml +172 -0
- package/ansible/ubuntu/tasks/install.yml +2 -1
- package/ansible/ubuntu/tasks/packages.yml +4 -2
- package/ansible/ubuntu/tasks/release.yml +6 -0
- package/ansible/ubuntu/tasks/resources.yml +6 -4
- package/ansible/ubuntu/tasks/services.yml +2 -2
- package/ansible/ubuntu/tasks/sizing.yml +49 -0
- package/ansible/ubuntu/tasks/snapshot.yml +14 -3
- package/ansible/ubuntu/tasks/ssh.yml +61 -4
- package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
- package/ansible/ubuntu/tasks/user.yml +1 -0
- package/ansible/ubuntu/worker-account.yml +117 -0
- package/ansible/uv.lock +265 -0
- package/bin/devfleet.mjs +42 -4
- package/bootstrap/fleet-remote-bootstrap.sh +91 -25
- package/bootstrap/global-config.ts +29 -3
- package/bootstrap/package-smoke.ts +127 -0
- package/bootstrap/pins.ts +189 -0
- package/bootstrap/release-notes.ts +88 -0
- package/bootstrap/release-version.ts +41 -7
- package/bootstrap/worker-features.ts +114 -0
- package/bootstrap/worker-seed.yaml +7 -3
- package/bootstrap/worker-tools.ts +39 -9
- package/cliproxy/README.md +1 -1
- package/cliproxy/docs/health-workflow.example.yml +20 -143
- package/docs/authentication.md +19 -11
- package/docs/consumer-setup.md +42 -27
- package/docs/convergence.md +67 -0
- package/docs/declared.md +61 -34
- package/docs/machine-setup.md +73 -7
- package/docs/manager-setup.md +10 -3
- package/docs/operations.md +64 -9
- package/docs/releasing.md +49 -1
- package/docs/runbooks.md +98 -7
- package/docs/state.md +23 -11
- package/docs/support-matrix.md +40 -4
- package/docs/topology.md +67 -6
- package/docs/ubuntu-sync.md +78 -28
- package/docs/worker-contract.md +122 -19
- package/docs/worker-requirements.md +107 -0
- package/examples/fleet.json +1 -0
- package/examples/workers-from-infrastructure/main.tf +132 -0
- package/examples/workers-from-infrastructure/worker-records.json +14 -0
- package/llms.txt +3 -1
- package/mise.lock +29 -0
- package/mise.toml +5 -3
- package/package.json +2 -2
- package/schema/check-ids.json +1967 -170
- package/schema/check-ids.schema.json +34 -0
- package/schema/fleet.schema.json +327 -8
- package/schema/output/check.schema.json +1 -1
- package/schema/output/fleet-state-definition.schema.json +14 -0
- package/schema/output/fleet-sync-schedule.schema.json +26 -0
- package/schema/output/fleet-sync.schema.json +18 -0
- package/schema/output/report.schema.json +3 -2
- package/schema/worker-record.schema.json +76 -0
- package/src/agents/converge.ts +140 -24
- package/src/agents/files.ts +2 -32
- package/src/agents/native-verify.ts +34 -3
- package/src/agents/secrets.ts +39 -4
- package/src/check-owners.ts +61 -2
- package/src/check-registry.ts +630 -0
- package/src/cli/agent-guide.ts +3 -3
- package/src/cli/cli.ts +4 -0
- package/src/cli/fleet-manifest.ts +9 -3
- package/src/cli/fleet-node-lifecycle.ts +313 -64
- package/src/cli/fleet-node-park.ts +238 -0
- package/src/cli/fleet-node-seed.ts +51 -35
- package/src/cli/fleet-state.ts +2 -1
- package/src/cli/fleet-sync-checkout.ts +7 -0
- package/src/cli/fleet-sync-enrollment.ts +193 -32
- package/src/cli/fleet-sync-forward.ts +4 -0
- package/src/cli/fleet-sync-manager.ts +103 -33
- package/src/cli/fleet-sync-output.ts +31 -11
- package/src/cli/fleet-sync-plan.ts +65 -9
- package/src/cli/fleet-sync-records.ts +113 -0
- package/src/cli/fleet-sync-remote.ts +151 -9
- package/src/cli/fleet-sync-schedule.ts +14 -4
- package/src/cli/fleet-sync-state.ts +28 -16
- package/src/cli/fleet-sync-vault.ts +77 -0
- package/src/cli/fleet-sync-worker.ts +214 -0
- package/src/cli/fleet-sync.ts +150 -19
- package/src/cli/fleet-validate.ts +59 -21
- package/src/cli/fleet-watch.ts +7 -1
- package/src/cli/fleet.ts +87 -5
- package/src/cli/managed-health.ts +25 -1
- package/src/cli/register-fleet.ts +115 -4
- package/src/cli/registry.ts +14 -0
- package/src/cli/tailnet.ts +43 -10
- package/src/cli/ui-data.ts +3 -1
- package/src/cli/worker-usage-report.ts +63 -0
- package/src/clock.ts +42 -0
- package/src/definition.ts +332 -9
- package/src/diagnostics.ts +5 -2
- package/src/exposure.ts +102 -0
- package/src/fleet-config.ts +3 -1
- package/src/git-identity.ts +76 -3
- package/src/ide/ide.ts +5 -0
- package/src/ide/t3.ts +12 -2
- package/src/input-hashes.ts +6 -0
- package/src/machine/machine-class.ts +59 -0
- package/src/machine/platform.ts +90 -11
- package/src/machine/state.ts +37 -15
- package/src/machine/support-matrix.ts +11 -9
- package/src/machine-health.ts +28 -5
- package/src/manager-health.ts +148 -2
- package/src/manager-sync.ts +65 -4
- package/src/monitoring/beszel.ts +8 -8
- package/src/monitoring/enroll.ts +8 -1
- package/src/monitoring/hub-access.ts +10 -4
- package/src/monitoring/systems.ts +138 -0
- package/src/network-definition.ts +13 -4
- package/src/node-converge-linux.ts +136 -27
- package/src/node-converge.ts +13 -3
- package/src/platform/ansible-receipt.ts +17 -2
- package/src/platform/ansible.ts +40 -4
- package/src/platform/manager-host.ts +25 -8
- package/src/platform/resource-verify.ts +18 -7
- package/src/platform/t3-policy.ts +24 -10
- package/src/platform/t3-service.ts +109 -34
- package/src/platform/ubuntu-release.ts +24 -0
- package/src/platform/ubuntu-verify.ts +31 -3
- package/src/platform/ubuntu-worker.ts +58 -0
- package/src/platform/worker-platform.ts +72 -0
- package/src/protocol.ts +1 -1
- package/src/public-reachability.ts +186 -0
- package/src/remote-bootstrap.ts +50 -12
- package/src/remote-operation.ts +18 -4
- package/src/repair-plan.ts +23 -12
- package/src/report.ts +2 -1
- package/src/schedule-check.ts +21 -2
- package/src/secrets.ts +4 -0
- package/src/service-auth.ts +55 -48
- package/src/ssh-connection.ts +141 -0
- package/src/ssh.ts +63 -24
- package/src/state/store.ts +4 -2
- package/src/state/sync.ts +35 -6
- package/src/state/workers.ts +140 -0
- package/src/t3/pairing.ts +59 -0
- package/src/t3/peer-link.ts +333 -0
- package/src/t3/version.ts +1 -1
- package/src/tailnet-cleanup.ts +185 -0
- package/src/tailnet-control.ts +346 -0
- package/src/tailscale.ts +1 -1
- package/src/toolchain-checks.ts +11 -1
- package/src/types.ts +4 -2
- package/src/worker-account.ts +63 -0
- package/src/worker-activity.ts +303 -0
- package/src/worker-capacity.ts +31 -0
- package/src/worker-exposure.ts +388 -0
- package/src/worker-features.ts +201 -0
- package/src/worker-host-checks.ts +71 -20
- package/src/worker-manifest.ts +27 -4
- package/src/worker-parking.ts +70 -0
- package/src/worker-policy.ts +17 -1
- package/src/worker-readiness.ts +15 -3
- package/src/worker-reboot.ts +129 -0
- package/src/worker-records.ts +305 -0
- package/src/worker-seed.ts +16 -11
- package/src/worker-sizing.ts +76 -0
- package/src/cli/schedule-logs.ts +0 -36
|
@@ -109,11 +109,15 @@ export function forwardedFlags(
|
|
|
109
109
|
const switches: [boolean | undefined, string][] = [
|
|
110
110
|
[options.all, '--all'],
|
|
111
111
|
[options.vaultPasswordLogin, '--vault-password-login'],
|
|
112
|
+
[options.restartBusy, '--restart-busy'],
|
|
112
113
|
[options.dryRun, '--dry-run'],
|
|
113
114
|
[options.apply, '--apply'],
|
|
114
115
|
[options.yes, '--yes'],
|
|
115
116
|
[options.json, '--json'],
|
|
116
117
|
[options.nonInteractive, '--non-interactive'],
|
|
118
|
+
[options.maintenanceReboot, '--maintenance-reboot'],
|
|
119
|
+
[options.force, '--force'],
|
|
120
|
+
[options.takeOver, '--take-over'],
|
|
117
121
|
];
|
|
118
122
|
for (const [on, flag] of switches) if (on) flags.push(flag);
|
|
119
123
|
return flags;
|
|
@@ -13,12 +13,16 @@ import { IDES } from '../ide';
|
|
|
13
13
|
import { enabled, managerChecks } from '../ide/orchestrate';
|
|
14
14
|
import { idesOf } from '../ide/definition';
|
|
15
15
|
import { convergeGitIdentity, gitChecks } from '../git-identity';
|
|
16
|
-
import { ansibleCommand
|
|
16
|
+
import { ansibleCommand } from '../platform/ansible';
|
|
17
|
+
import { runAnsibleWithReceipt } from '../platform/ansible-receipt';
|
|
18
|
+
import macos from '../../ansible/macos/policy.json';
|
|
19
|
+
import { scheduleIntervals } from '../schedule-check';
|
|
17
20
|
import { convergeGithub } from '../github/converge';
|
|
18
21
|
import { PACKAGE_ROOT } from '../package-info';
|
|
19
22
|
import { managerHealth } from '../manager-health';
|
|
23
|
+
import { holdManagerVault } from './fleet-sync-vault';
|
|
20
24
|
import { readFileSync } from 'node:fs';
|
|
21
|
-
import { homedir } from 'node:os';
|
|
25
|
+
import { homedir, userInfo } from 'node:os';
|
|
22
26
|
import {
|
|
23
27
|
MACOS_MANAGER,
|
|
24
28
|
managerHost,
|
|
@@ -49,11 +53,20 @@ export async function convergeManager(
|
|
|
49
53
|
if (input.apply) installConfig();
|
|
50
54
|
checks.push(
|
|
51
55
|
input.apply
|
|
52
|
-
? await
|
|
56
|
+
? await convergeManagerPolicy(run, definition, host)
|
|
53
57
|
: await managerPower(run, input.workspace, host)
|
|
54
58
|
);
|
|
59
|
+
if (input.apply)
|
|
60
|
+
checks.push(await managerPower(run, input.workspace, host));
|
|
55
61
|
checks.push(await managerBootRecovery(run, input.workspace, host));
|
|
56
62
|
checks.push(await managerDocker(run));
|
|
63
|
+
// The renewal job is a launch agent: macOS managers only.
|
|
64
|
+
if (host.platform === 'macOS')
|
|
65
|
+
checks.push(
|
|
66
|
+
...(await attempt('manager:vault-renewal', () =>
|
|
67
|
+
holdManagerVault(run, { apply: input.apply, root: PACKAGE_ROOT })
|
|
68
|
+
))
|
|
69
|
+
);
|
|
57
70
|
// Each enabled IDE: its settings, pinned version and policy.
|
|
58
71
|
checks.push(
|
|
59
72
|
...(await managerChecks(enabled(idesOf(definition), IDES), {
|
|
@@ -231,19 +244,24 @@ export async function managerIdentity(
|
|
|
231
244
|
|
|
232
245
|
/** Workers depend on the manager's services, so it never sleeps on AC power.
|
|
233
246
|
* Measures every setting `ansible/macos/manager.yml` declares, from the same
|
|
234
|
-
* policy file the playbook applies.
|
|
247
|
+
* policy file the playbook applies. A setting pmset does not list on AC power
|
|
248
|
+
* is one this Mac does not have (a virtual Mac has no womp): the playbook
|
|
249
|
+
* skips it, so it is not applicable here either.
|
|
235
250
|
*/
|
|
236
251
|
export async function managerPower(
|
|
237
252
|
run: Runner,
|
|
238
253
|
workspace = '.',
|
|
239
254
|
host: ManagerHost = MACOS_MANAGER
|
|
240
255
|
): Promise<Check> {
|
|
241
|
-
const drifted = await host.powerDrift(run);
|
|
256
|
+
const { drifted, notApplicable } = await host.powerDrift(run);
|
|
242
257
|
return drifted.length === 0
|
|
243
258
|
? {
|
|
244
259
|
id: 'manager:power',
|
|
245
260
|
status: 'pass',
|
|
246
|
-
summary:
|
|
261
|
+
summary:
|
|
262
|
+
notApplicable.length === 0
|
|
263
|
+
? 'The manager never sleeps on AC power.'
|
|
264
|
+
: `The manager never sleeps on AC power; not applicable on this Mac: ${notApplicable.join(' ')}.`,
|
|
247
265
|
}
|
|
248
266
|
: {
|
|
249
267
|
id: 'manager:power',
|
|
@@ -291,41 +309,93 @@ export async function managerBootRecovery(
|
|
|
291
309
|
};
|
|
292
310
|
}
|
|
293
311
|
|
|
294
|
-
/**
|
|
295
|
-
|
|
296
|
-
*/
|
|
297
|
-
async function powerGranted(run: Runner): Promise<boolean> {
|
|
298
|
-
return (
|
|
299
|
-
await run(['sudo', '-n', '-l', '/usr/bin/pmset', '-c', 'sleep', '0'], {
|
|
300
|
-
timeoutMs: 10_000,
|
|
301
|
-
})
|
|
302
|
-
).ok;
|
|
303
|
-
}
|
|
312
|
+
/** The receipt of the manager's own playbook, beside the workers' ones. */
|
|
313
|
+
export const MANAGER_RECEIPT = 'macos-manager';
|
|
304
314
|
|
|
305
|
-
/** On every applying sync
|
|
306
|
-
*
|
|
307
|
-
*
|
|
315
|
+
/** On every applying sync the manager applies `ansible/macos/manager.yml`
|
|
316
|
+
* like a worker applies its baseline: in check mode first, then for real,
|
|
317
|
+
* with a receipt that skips an unchanged playbook while its postconditions
|
|
318
|
+
* hold, and a full re-apply at least once a day. It never asks for a
|
|
319
|
+
* password: what needs root goes through the grant manager-privilege.yml
|
|
320
|
+
* installs once, and the playbook skips it without the grant.
|
|
308
321
|
*/
|
|
309
|
-
export async function
|
|
322
|
+
export async function convergeManagerPolicy(
|
|
310
323
|
run: Runner,
|
|
311
|
-
|
|
324
|
+
definition: Pick<FleetDefinition, 'manager'>,
|
|
312
325
|
host: ManagerHost = MACOS_MANAGER,
|
|
313
|
-
{
|
|
326
|
+
{
|
|
327
|
+
root = PACKAGE_ROOT,
|
|
328
|
+
scratch,
|
|
329
|
+
receipts,
|
|
330
|
+
uid = userInfo().uid,
|
|
331
|
+
now,
|
|
332
|
+
}: {
|
|
333
|
+
root?: string;
|
|
334
|
+
scratch?: string;
|
|
335
|
+
receipts?: string;
|
|
336
|
+
uid?: number;
|
|
337
|
+
now?: number;
|
|
338
|
+
} = {}
|
|
314
339
|
): Promise<Check> {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
await runAnsible(
|
|
340
|
+
if (host.platform !== 'macOS')
|
|
341
|
+
return {
|
|
342
|
+
id: 'manager:playbook',
|
|
343
|
+
status: 'not-applicable',
|
|
344
|
+
summary: 'The manager playbook is for a macOS manager.',
|
|
345
|
+
};
|
|
346
|
+
const result = await runAnsibleWithReceipt(
|
|
323
347
|
run,
|
|
324
348
|
join(root, 'ansible/macos/manager.yml'),
|
|
325
|
-
{
|
|
326
|
-
|
|
349
|
+
{
|
|
350
|
+
cwd: join(root, 'ansible/macos'),
|
|
351
|
+
timeoutMs: 300_000,
|
|
352
|
+
input: JSON.stringify({
|
|
353
|
+
fleet_watch_minutes: scheduleIntervals(definition.manager).watchMinutes,
|
|
354
|
+
}),
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
name: MANAGER_RECEIPT,
|
|
358
|
+
sources: [join(root, 'ansible/macos')],
|
|
359
|
+
checkFirst: true,
|
|
360
|
+
verify: async () =>
|
|
361
|
+
(await host.powerDrift(run)).drifted.length === 0 &&
|
|
362
|
+
(await watchLoaded(run, uid)),
|
|
363
|
+
...(receipts ? { directory: receipts } : {}),
|
|
364
|
+
...(scratch ? { scratch } : {}),
|
|
365
|
+
...(now === undefined ? {} : { now }),
|
|
366
|
+
}
|
|
327
367
|
);
|
|
328
|
-
|
|
368
|
+
if (result.ok)
|
|
369
|
+
return {
|
|
370
|
+
id: 'manager:playbook',
|
|
371
|
+
status: 'pass',
|
|
372
|
+
summary: result.cached
|
|
373
|
+
? 'The manager playbook is unchanged since it was last applied, and its postconditions hold.'
|
|
374
|
+
: 'The manager playbook was checked, then applied.',
|
|
375
|
+
};
|
|
376
|
+
return {
|
|
377
|
+
id: 'manager:playbook',
|
|
378
|
+
status: 'fail',
|
|
379
|
+
summary:
|
|
380
|
+
result.stage === 'check'
|
|
381
|
+
? 'MANAGER_PLAYBOOK_CHECK_FAILED'
|
|
382
|
+
: 'MANAGER_PLAYBOOK_FAILED',
|
|
383
|
+
remedy: {
|
|
384
|
+
action: `Run the playbook in check mode on the manager and read the failing task: ${ansibleCommand().join(' ')} --check --diff ${join(root, 'ansible/macos/manager.yml')}. Nothing in it needs a password; what needs root comes from the one grant (manager-privilege.yml).`,
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Whether launchd holds the fleet-watch job manager.yml installs. Reads only. */
|
|
390
|
+
export async function watchLoaded(run: Runner, uid: number): Promise<boolean> {
|
|
391
|
+
return (
|
|
392
|
+
await run(
|
|
393
|
+
['/bin/launchctl', 'print', `gui/${uid}/${macos.fleetWatch.label}`],
|
|
394
|
+
{
|
|
395
|
+
timeoutMs: 10_000,
|
|
396
|
+
}
|
|
397
|
+
)
|
|
398
|
+
).ok;
|
|
329
399
|
}
|
|
330
400
|
|
|
331
401
|
/** The Docker Desktop release `mise.toml` pins, as the engine names itself. */
|
|
@@ -51,17 +51,19 @@ const COLUMNS: { title: string; matches: (id: string) => boolean }[] = [
|
|
|
51
51
|
},
|
|
52
52
|
];
|
|
53
53
|
|
|
54
|
-
const ORDER: Status[] = ['fail', 'warn', 'skip', 'pass'];
|
|
54
|
+
const ORDER: Status[] = ['fail', 'warn', 'skip', 'not-applicable', 'pass'];
|
|
55
55
|
const WORD: Record<Status, string> = {
|
|
56
56
|
fail: 'FAIL',
|
|
57
57
|
warn: 'WARN',
|
|
58
58
|
skip: 'SKIP',
|
|
59
|
+
'not-applicable': 'N/A',
|
|
59
60
|
pass: 'PASS',
|
|
60
61
|
};
|
|
61
62
|
const SYMBOL: Record<Status, string> = {
|
|
62
63
|
fail: '✖',
|
|
63
64
|
warn: '▲',
|
|
64
65
|
skip: '○',
|
|
66
|
+
'not-applicable': '–',
|
|
65
67
|
pass: '✔',
|
|
66
68
|
};
|
|
67
69
|
|
|
@@ -104,7 +106,7 @@ export function managerText(
|
|
|
104
106
|
const lines: string[] = [header(c, report, failing, options)];
|
|
105
107
|
lines.push('', ...grid(c, report.nodes, mark, options.color === true));
|
|
106
108
|
lines.push('', totals(c, counts, options.color === true));
|
|
107
|
-
for (const status of ['fail', 'warn', 'skip'] as const) {
|
|
109
|
+
for (const status of ['fail', 'warn', 'skip', 'not-applicable'] as const) {
|
|
108
110
|
const problems = groupProblems(
|
|
109
111
|
all.filter((entry) => entry.check.status === status)
|
|
110
112
|
);
|
|
@@ -120,6 +122,8 @@ export function managerText(
|
|
|
120
122
|
lines.push(
|
|
121
123
|
`${c.dim('report ')} ${options.reportPath} ${c.dim('(full JSON; or rerun with --json)')}`
|
|
122
124
|
);
|
|
125
|
+
if (report.enrolled?.length)
|
|
126
|
+
lines.push(`${c.dim('enrolled ')} ${report.enrolled.join(', ')}`);
|
|
123
127
|
if (report.candidates)
|
|
124
128
|
lines.push(
|
|
125
129
|
`${c.dim('tailnet ')} ${report.candidates} unmanaged device(s) seen; discovery never enrolls them`
|
|
@@ -138,10 +142,11 @@ export function stableUrls(text: string): string {
|
|
|
138
142
|
return text.replace(TAILNET_URL, 'its private tailnet address');
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
const SECTION: Record<'fail' | 'warn' | 'skip', string> = {
|
|
145
|
+
const SECTION: Record<'fail' | 'warn' | 'skip' | 'not-applicable', string> = {
|
|
142
146
|
fail: 'Failing — fix these',
|
|
143
147
|
warn: 'Warnings — outside the fleet; sync still succeeds',
|
|
144
148
|
skip: 'Not observable on this hardware',
|
|
149
|
+
'not-applicable': 'Not applicable to this machine — with the reason',
|
|
145
150
|
};
|
|
146
151
|
|
|
147
152
|
function header(
|
|
@@ -268,22 +273,32 @@ function totals(
|
|
|
268
273
|
counts: Record<Status, number>,
|
|
269
274
|
color: boolean
|
|
270
275
|
): string {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
// Not-applicable checks are listed only on machines that have some.
|
|
277
|
+
return ORDER.filter((status) => status !== 'not-applicable' || counts[status])
|
|
278
|
+
.map((status) => {
|
|
279
|
+
const text = `${color ? `${SYMBOL[status]} ` : ''}${counts[status]} ${LABEL[status]}`;
|
|
280
|
+
// A zero count is not a signal: never show red/yellow for "0 failing".
|
|
281
|
+
return counts[status] ? paint(c, status, text) : c.dim(text);
|
|
282
|
+
})
|
|
283
|
+
.join(c.dim(' '));
|
|
276
284
|
}
|
|
277
285
|
|
|
278
286
|
const LABEL: Record<Status, string> = {
|
|
279
287
|
fail: 'failing',
|
|
280
288
|
warn: 'warnings',
|
|
281
289
|
skip: 'skipped',
|
|
290
|
+
'not-applicable': 'not applicable',
|
|
282
291
|
pass: 'passing',
|
|
283
292
|
};
|
|
284
293
|
|
|
285
294
|
function count(checks: Check[]): Record<Status, number> {
|
|
286
|
-
const counts: Record<Status, number> = {
|
|
295
|
+
const counts: Record<Status, number> = {
|
|
296
|
+
pass: 0,
|
|
297
|
+
warn: 0,
|
|
298
|
+
fail: 0,
|
|
299
|
+
skip: 0,
|
|
300
|
+
'not-applicable': 0,
|
|
301
|
+
};
|
|
287
302
|
for (const check of checks)
|
|
288
303
|
counts[
|
|
289
304
|
blocking(check) || check.status !== 'fail' ? check.status : 'warn'
|
|
@@ -295,7 +310,12 @@ function worst(checks: Check[]): Status | null {
|
|
|
295
310
|
if (!checks.length) return null;
|
|
296
311
|
if (checks.some(blocking)) return 'fail';
|
|
297
312
|
if (
|
|
298
|
-
checks.some(
|
|
313
|
+
checks.some(
|
|
314
|
+
(check) =>
|
|
315
|
+
check.status !== 'pass' &&
|
|
316
|
+
check.status !== 'skip' &&
|
|
317
|
+
check.status !== 'not-applicable'
|
|
318
|
+
)
|
|
299
319
|
)
|
|
300
320
|
return 'warn';
|
|
301
321
|
return checks.every((check) => check.status === 'skip') ? 'skip' : 'pass';
|
|
@@ -306,7 +326,7 @@ function paint(c: ChalkInstance, status: Status, text: string): string {
|
|
|
306
326
|
? c.red(text)
|
|
307
327
|
: status === 'warn'
|
|
308
328
|
? c.yellow(text)
|
|
309
|
-
: status === 'skip'
|
|
329
|
+
: status === 'skip' || status === 'not-applicable'
|
|
310
330
|
? c.dim(text)
|
|
311
331
|
: c.green(text);
|
|
312
332
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import type { Standing } from '../state/sync';
|
|
3
3
|
import type { Runner } from '../types';
|
|
4
|
+
import { featureRemovals, workerFeatures } from '../worker-features';
|
|
4
5
|
|
|
5
6
|
/** A tool whose pin changes between the applied and the selected revision. */
|
|
6
7
|
export interface ToolChange {
|
|
@@ -31,6 +32,9 @@ export interface SyncPlan {
|
|
|
31
32
|
tools: ToolChange[];
|
|
32
33
|
restarts: Restart[];
|
|
33
34
|
nodes: { node: string; from: string | null; to: string; changes: boolean }[];
|
|
35
|
+
/** What a sync removes from a worker because the fleet configuration
|
|
36
|
+
* turned one of its features off since it was last applied. */
|
|
37
|
+
removals: { node: string; removes: string[] }[];
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/** The coding agents' CLIs: a new version is used by the next session, and a
|
|
@@ -84,16 +88,24 @@ export function toolChanges(
|
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
/** What restarts follow from the changes, as the playbooks and the toolchain
|
|
87
|
-
* setup act on them.
|
|
91
|
+
* setup act on them. A worker's T3 restarts only while it runs no agent turn
|
|
92
|
+
* (`ansible/ubuntu/tasks/t3-restart.yml`) unless `--restart-busy` asks for it
|
|
93
|
+
* anyway: only then do running turns end.
|
|
88
94
|
*/
|
|
89
|
-
export function restarts(
|
|
95
|
+
export function restarts(
|
|
96
|
+
tools: ToolChange[],
|
|
97
|
+
files: string[],
|
|
98
|
+
restartBusy = false
|
|
99
|
+
): Restart[] {
|
|
90
100
|
const found: Restart[] = [];
|
|
101
|
+
const when = restartBusy
|
|
102
|
+
? 'at once (--restart-busy): its running agent turns end.'
|
|
103
|
+
: 'once it runs no agent turn: running turns keep going and the restart is pending until then.';
|
|
91
104
|
if (tools.some((change) => change.tool === 't3'))
|
|
92
105
|
found.push({
|
|
93
106
|
service: 't3',
|
|
94
|
-
reason:
|
|
95
|
-
|
|
96
|
-
endsRunningTurns: true,
|
|
107
|
+
reason: `T3 is upgraded on every worker and restarted ${when}`,
|
|
108
|
+
endsRunningTurns: restartBusy,
|
|
97
109
|
});
|
|
98
110
|
else if (
|
|
99
111
|
files.some((file) =>
|
|
@@ -102,9 +114,8 @@ export function restarts(tools: ToolChange[], files: string[]): Restart[] {
|
|
|
102
114
|
)
|
|
103
115
|
found.push({
|
|
104
116
|
service: 't3',
|
|
105
|
-
reason:
|
|
106
|
-
|
|
107
|
-
endsRunningTurns: true,
|
|
117
|
+
reason: `The managed T3 policy may change: a T3 that has not loaded it is restarted ${when}`,
|
|
118
|
+
endsRunningTurns: restartBusy,
|
|
108
119
|
});
|
|
109
120
|
const agents = tools.filter((change) => AGENT_TOOLS.includes(change.tool));
|
|
110
121
|
if (agents.length)
|
|
@@ -136,6 +147,11 @@ export async function syncPlan(
|
|
|
136
147
|
state: Standing;
|
|
137
148
|
nodes: string[];
|
|
138
149
|
sources: string[];
|
|
150
|
+
/** `--restart-busy`: a T3 restart does not wait for idle. */
|
|
151
|
+
restartBusy?: boolean;
|
|
152
|
+
/** The fleet configuration last applied (from the fleet state), when
|
|
153
|
+
* there is one, and the one this sync applies. */
|
|
154
|
+
definitions?: { applied: unknown; selected: unknown };
|
|
139
155
|
},
|
|
140
156
|
run: Runner
|
|
141
157
|
): Promise<SyncPlan> {
|
|
@@ -163,16 +179,54 @@ export async function syncPlan(
|
|
|
163
179
|
revision: { from: input.from, to: input.to },
|
|
164
180
|
known: changes !== null,
|
|
165
181
|
tools: changes?.tools ?? [],
|
|
166
|
-
restarts: changes
|
|
182
|
+
restarts: changes
|
|
183
|
+
? restarts(changes.tools, changes.files, input.restartBusy === true)
|
|
184
|
+
: [],
|
|
167
185
|
nodes: input.nodes.map((node) => ({
|
|
168
186
|
node,
|
|
169
187
|
from: input.from,
|
|
170
188
|
to: input.to,
|
|
171
189
|
changes: differs,
|
|
172
190
|
})),
|
|
191
|
+
removals: input.definitions ? removals(input.nodes, input.definitions) : [],
|
|
173
192
|
};
|
|
174
193
|
}
|
|
175
194
|
|
|
195
|
+
/** Per worker, what turning its features off removes. Nothing is known to
|
|
196
|
+
* be removed from a worker the applied configuration does not name. */
|
|
197
|
+
function removals(
|
|
198
|
+
nodes: string[],
|
|
199
|
+
definitions: { applied: unknown; selected: unknown }
|
|
200
|
+
): SyncPlan['removals'] {
|
|
201
|
+
const workers = (definition: unknown): Record<string, unknown>[] => {
|
|
202
|
+
const list =
|
|
203
|
+
typeof definition === 'object' && definition !== null
|
|
204
|
+
? (definition as Record<string, unknown>)['workers']
|
|
205
|
+
: undefined;
|
|
206
|
+
return Array.isArray(list)
|
|
207
|
+
? list.filter(
|
|
208
|
+
(entry): entry is Record<string, unknown> =>
|
|
209
|
+
typeof entry === 'object' && entry !== null
|
|
210
|
+
)
|
|
211
|
+
: [];
|
|
212
|
+
};
|
|
213
|
+
return nodes.flatMap((node) => {
|
|
214
|
+
const before = workers(definitions.applied).find(
|
|
215
|
+
(entry) => entry['id'] === node
|
|
216
|
+
);
|
|
217
|
+
if (!before) return [];
|
|
218
|
+
const network = workers(definitions.selected).find(
|
|
219
|
+
(entry) => entry['id'] === node
|
|
220
|
+
)?.['network'] as { user?: unknown } | undefined;
|
|
221
|
+
const removes = featureRemovals(
|
|
222
|
+
workerFeatures(definitions.applied, node),
|
|
223
|
+
workerFeatures(definitions.selected, node),
|
|
224
|
+
typeof network?.user === 'string' ? network.user : 'the worker user'
|
|
225
|
+
);
|
|
226
|
+
return removes.length ? [{ node, removes }] : [];
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
176
230
|
export const planSources = (workspace: string, root: string): string[] => [
|
|
177
231
|
workspace,
|
|
178
232
|
join(root, 'source'),
|
|
@@ -196,5 +250,7 @@ export function planText(plan: SyncPlan): string {
|
|
|
196
250
|
);
|
|
197
251
|
for (const restart of plan.restarts)
|
|
198
252
|
lines.push(` Restarts ${restart.service}: ${restart.reason}`);
|
|
253
|
+
for (const removal of plan.removals ?? [])
|
|
254
|
+
lines.push(` Removes from ${removal.node}: ${removal.removes.join(', ')}`);
|
|
199
255
|
return lines.join('\n');
|
|
200
256
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { FleetDefinition, MachineDefinition } from '../definition';
|
|
2
|
+
import { readRosterText } from '../state/roster';
|
|
3
|
+
import { changeWorkers, readWorkerStates } from '../state/workers';
|
|
4
|
+
import { parseConfig } from '../config';
|
|
5
|
+
import type { Check, FleetNode, Runner } from '../types';
|
|
6
|
+
import {
|
|
7
|
+
lifecycleServices,
|
|
8
|
+
retireWorker,
|
|
9
|
+
type NodeLifecycleServices,
|
|
10
|
+
} from './fleet-node-lifecycle';
|
|
11
|
+
|
|
12
|
+
/** The workers of the fleet as worker records describe them come and go
|
|
13
|
+
* with the records: a record that appears is a declared worker, enrolled by
|
|
14
|
+
* the next sync like any other; one that disappears is retired by the same
|
|
15
|
+
* path as fleet-node-retire. fleet-sync calls this before it reads the
|
|
16
|
+
* roster. It records which workers came from records, so that only those
|
|
17
|
+
* are ever retired by themselves; a worker removed from fleet.json by hand
|
|
18
|
+
* must still be retired first.
|
|
19
|
+
*/
|
|
20
|
+
export async function reconcileRecords(
|
|
21
|
+
definition: FleetDefinition,
|
|
22
|
+
roster: string,
|
|
23
|
+
run: Runner,
|
|
24
|
+
{
|
|
25
|
+
apply,
|
|
26
|
+
services = lifecycleServices,
|
|
27
|
+
}: {
|
|
28
|
+
apply: boolean;
|
|
29
|
+
services?: (roster: string) => NodeLifecycleServices;
|
|
30
|
+
}
|
|
31
|
+
): Promise<Check[]> {
|
|
32
|
+
const states = readWorkerStates().workers;
|
|
33
|
+
const declared = new Set(definition.workers.map((worker) => worker.id));
|
|
34
|
+
const vanished = Object.entries(states)
|
|
35
|
+
.filter(([id, entry]) => entry.origin === 'records' && !declared.has(id))
|
|
36
|
+
.map(([id]) => id);
|
|
37
|
+
const entries = await rosterEntries(roster);
|
|
38
|
+
const lifecycle = { ...services(roster), run };
|
|
39
|
+
const checks: Check[] = [];
|
|
40
|
+
for (const id of vanished) {
|
|
41
|
+
const retired = await retireWorker(
|
|
42
|
+
gone(id, entries.get(id)),
|
|
43
|
+
entries.get(id),
|
|
44
|
+
lifecycle,
|
|
45
|
+
apply
|
|
46
|
+
);
|
|
47
|
+
const steps = (retired.data as { nodes: { checks: Check[] }[] }).nodes[0]!
|
|
48
|
+
.checks;
|
|
49
|
+
const failed = steps.filter((step) => step.status === 'fail');
|
|
50
|
+
checks.push(
|
|
51
|
+
failed.length
|
|
52
|
+
? {
|
|
53
|
+
id: 'enrollment:record',
|
|
54
|
+
status: 'fail',
|
|
55
|
+
summary: 'WORKER_RECORD_RETIREMENT_FAILED',
|
|
56
|
+
remedy: {
|
|
57
|
+
action: `${id}'s worker record disappeared, and retiring it failed at ${failed.map((step) => step.id).join(', ')}. Follow that remedy, then run fleet-sync again.`,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
: {
|
|
61
|
+
id: 'enrollment:record',
|
|
62
|
+
status: apply ? 'pass' : 'skip',
|
|
63
|
+
summary: `${apply ? '' : 'Planned: '}${id}'s worker record disappeared, so it is retired: ${steps.map((step) => step.id).join(', ')}.`,
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const fresh = definition.workers.filter(
|
|
68
|
+
(worker) => worker.origin === 'records' && !states[worker.id]?.origin
|
|
69
|
+
);
|
|
70
|
+
if (apply && fresh.length)
|
|
71
|
+
await changeWorkers(lifecycle.state(), (current) =>
|
|
72
|
+
Object.fromEntries(
|
|
73
|
+
fresh.map((worker) => [
|
|
74
|
+
worker.id,
|
|
75
|
+
{ ...current[worker.id], origin: 'records' as const },
|
|
76
|
+
])
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
return checks;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function rosterEntries(path: string): Promise<Map<string, FleetNode>> {
|
|
83
|
+
const text = await readRosterText(path).catch(() => null);
|
|
84
|
+
if (text === null) return new Map();
|
|
85
|
+
try {
|
|
86
|
+
return new Map(
|
|
87
|
+
parseConfig(JSON.parse(text) as unknown).nodes.map((node) => [
|
|
88
|
+
node.id,
|
|
89
|
+
node,
|
|
90
|
+
])
|
|
91
|
+
);
|
|
92
|
+
} catch {
|
|
93
|
+
return new Map();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A worker whose record is gone, as far as retiring it needs: reached from
|
|
98
|
+
* declared data (so its pin is the fleet state's), under the name the roster
|
|
99
|
+
* last knew. Its user and ports are never used to retire it.
|
|
100
|
+
*/
|
|
101
|
+
function gone(id: string, entry: FleetNode | undefined): MachineDefinition {
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
os: 'linux',
|
|
105
|
+
origin: 'records',
|
|
106
|
+
network: {
|
|
107
|
+
tailscaleName: entry?.ssh ?? id,
|
|
108
|
+
user: 'root',
|
|
109
|
+
sshPort: 22,
|
|
110
|
+
t3Port: 3773,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|