@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
package/src/cli/fleet-sync.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { managerFailure } from '../manager-failure';
|
|
9
9
|
import { managerText, stdoutColor } from './fleet-sync-output';
|
|
10
10
|
import { managerSchedule, type ScheduleAction } from './fleet-sync-schedule';
|
|
11
|
-
import { enrollManager } from './fleet-sync-enrollment';
|
|
11
|
+
import { enrollManager, type EnrollmentResult } from './fleet-sync-enrollment';
|
|
12
12
|
import { authorizeManager } from './fleet-sync-vault';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
@@ -20,14 +20,14 @@ import {
|
|
|
20
20
|
type Enrollment,
|
|
21
21
|
} from '../machine/state';
|
|
22
22
|
import { loadEnrollment } from '../state/enrollment';
|
|
23
|
-
import { legacyHome } from '../state/paths';
|
|
23
|
+
import { legacyHome, stateRoot } from '../state/paths';
|
|
24
|
+
import { stateDefinition } from '../state/operator';
|
|
24
25
|
import {
|
|
25
26
|
appendRun,
|
|
26
27
|
summarizeRun,
|
|
27
28
|
type SyncRun,
|
|
28
29
|
type Trigger,
|
|
29
30
|
} from '../state/history';
|
|
30
|
-
import { capScheduleLogs } from './schedule-logs';
|
|
31
31
|
import {
|
|
32
32
|
DIVERGED_REMEDY,
|
|
33
33
|
mutateState,
|
|
@@ -58,6 +58,8 @@ import {
|
|
|
58
58
|
|
|
59
59
|
export interface FleetSyncOptions {
|
|
60
60
|
revision?: string;
|
|
61
|
+
/** Converge to the revision applied before the current one. */
|
|
62
|
+
rollback?: boolean;
|
|
61
63
|
dryRun?: boolean;
|
|
62
64
|
apply?: boolean;
|
|
63
65
|
yes?: boolean;
|
|
@@ -85,6 +87,23 @@ export interface FleetSyncOptions {
|
|
|
85
87
|
expectedPlanFingerprint?: string;
|
|
86
88
|
/** Take the manager lease from another manager before it expired. */
|
|
87
89
|
takeOver?: boolean;
|
|
90
|
+
/** Restart a worker's T3 for a new release or policy even while agent
|
|
91
|
+
* turns run, which ends them; by default the restart waits for idle.
|
|
92
|
+
*/
|
|
93
|
+
restartBusy?: boolean;
|
|
94
|
+
/** Opt-in: reboot each worker whose installed updates wait for a reboot,
|
|
95
|
+
* one at a time, canary first, only while it runs no agent turn, and
|
|
96
|
+
* verify it is ready before the next.
|
|
97
|
+
*/
|
|
98
|
+
maintenanceReboot?: boolean;
|
|
99
|
+
/** With --maintenance-reboot: reboot even while agent turns run. */
|
|
100
|
+
force?: boolean;
|
|
101
|
+
/** Internal: what this run's enrollment did, for the report. */
|
|
102
|
+
enrollment?: EnrollmentResult;
|
|
103
|
+
/** Internal: the workers that converged in an earlier run. One that never
|
|
104
|
+
* did may be rebooted once after its first converge.
|
|
105
|
+
*/
|
|
106
|
+
readyBefore?: string[];
|
|
88
107
|
}
|
|
89
108
|
export function machineRoot(): string {
|
|
90
109
|
const root = join(homedir(), '.local/share/personal/fleet/manager');
|
|
@@ -114,7 +133,6 @@ export async function fleetSync(
|
|
|
114
133
|
});
|
|
115
134
|
if (options.dryRun || options.schedule) return sync();
|
|
116
135
|
const trigger = runTrigger();
|
|
117
|
-
if (trigger === 'schedule') capScheduleLogs(join(machineRoot(), 'logs'));
|
|
118
136
|
const startedAt = new Date();
|
|
119
137
|
const outcome = await sync();
|
|
120
138
|
await recordRun(
|
|
@@ -186,17 +204,8 @@ export async function managerSync(
|
|
|
186
204
|
): Promise<CommandOutcome> {
|
|
187
205
|
await ensurePrivateRoot(root);
|
|
188
206
|
const state = await loadEnrollment(legacyHome() ? root : null);
|
|
189
|
-
const revision = await
|
|
190
|
-
|
|
191
|
-
state?.approvedRevision,
|
|
192
|
-
settings,
|
|
193
|
-
run
|
|
194
|
-
);
|
|
195
|
-
if (!isRevision(revision))
|
|
196
|
-
return actionRequired(
|
|
197
|
-
'FLEET_REVISION_REQUIRED',
|
|
198
|
-
'No revision to converge to. Run the published dev-fleet package (it names the commit it was built from), or select a full commit SHA with --revision.'
|
|
199
|
-
);
|
|
207
|
+
const revision = await managerRevision(options, state, settings, run);
|
|
208
|
+
if (typeof revision !== 'string') return revision;
|
|
200
209
|
if (options.schedule)
|
|
201
210
|
return scheduleRequest(options, revision, settings, run);
|
|
202
211
|
const rehomed = await rehome(
|
|
@@ -238,6 +247,33 @@ export async function managerSync(
|
|
|
238
247
|
});
|
|
239
248
|
}
|
|
240
249
|
|
|
250
|
+
/** The revision the manager converges to, or why there is none. */
|
|
251
|
+
async function managerRevision(
|
|
252
|
+
options: FleetSyncOptions,
|
|
253
|
+
state: Enrollment | null,
|
|
254
|
+
settings: Settings,
|
|
255
|
+
run: Runner
|
|
256
|
+
): Promise<string | CommandOutcome> {
|
|
257
|
+
const explicit = requestedRevision(options, state);
|
|
258
|
+
if (explicit === null)
|
|
259
|
+
return actionRequired(
|
|
260
|
+
'FLEET_ROLLBACK_UNAVAILABLE',
|
|
261
|
+
'Nothing was changed: the fleet state records no revision applied before the approved one. Install the dev-fleet version to go back to and run fleet-sync.'
|
|
262
|
+
);
|
|
263
|
+
const revision = await selectedRevision(
|
|
264
|
+
explicit,
|
|
265
|
+
state?.approvedRevision,
|
|
266
|
+
settings,
|
|
267
|
+
run
|
|
268
|
+
);
|
|
269
|
+
if (!isRevision(revision))
|
|
270
|
+
return actionRequired(
|
|
271
|
+
'FLEET_REVISION_REQUIRED',
|
|
272
|
+
'No revision to converge to. Run the published dev-fleet package (it names the commit it was built from), or select a full commit SHA with --revision.'
|
|
273
|
+
);
|
|
274
|
+
return revision;
|
|
275
|
+
}
|
|
276
|
+
|
|
241
277
|
/** The preview: the read-only report of every machine, and the plan of what
|
|
242
278
|
* a sync would change on them.
|
|
243
279
|
*/
|
|
@@ -262,6 +298,11 @@ async function preview(
|
|
|
262
298
|
.map((node) => node.node)
|
|
263
299
|
.filter((node): node is string => typeof node === 'string'),
|
|
264
300
|
sources: planSources(settings.workspace, context.root),
|
|
301
|
+
restartBusy: options.restartBusy === true,
|
|
302
|
+
definitions: {
|
|
303
|
+
applied: appliedDefinition(),
|
|
304
|
+
selected: canonicalDefinition,
|
|
305
|
+
},
|
|
265
306
|
},
|
|
266
307
|
context.run
|
|
267
308
|
);
|
|
@@ -272,6 +313,16 @@ async function preview(
|
|
|
272
313
|
};
|
|
273
314
|
}
|
|
274
315
|
|
|
316
|
+
/** The fleet configuration the fleet state records as last applied. */
|
|
317
|
+
function appliedDefinition(): unknown {
|
|
318
|
+
try {
|
|
319
|
+
const body = stateDefinition(stateRoot());
|
|
320
|
+
return body === null ? null : (JSON.parse(body) as unknown);
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
275
326
|
interface ApplyContext {
|
|
276
327
|
settings: Settings;
|
|
277
328
|
run: Runner;
|
|
@@ -288,15 +339,28 @@ async function authorizeAndApply(
|
|
|
288
339
|
context: ApplyContext
|
|
289
340
|
): Promise<CommandOutcome> {
|
|
290
341
|
const { settings, run, revision } = context;
|
|
291
|
-
const request = {
|
|
292
|
-
|
|
293
|
-
|
|
342
|
+
const request = {
|
|
343
|
+
...options,
|
|
344
|
+
revision,
|
|
345
|
+
apply: true,
|
|
346
|
+
yes: true,
|
|
347
|
+
readyBefore: readyBefore(state),
|
|
348
|
+
};
|
|
349
|
+
const person = interactive(options);
|
|
350
|
+
// A run without a person enrolls a worker only when every fact it needs
|
|
351
|
+
// is verified; it never prompts.
|
|
352
|
+
const enrollment = await enrollManager(settings.config, run, {
|
|
353
|
+
interactive: person,
|
|
354
|
+
joinable: Boolean(canonicalDefinition.tailnet),
|
|
355
|
+
});
|
|
356
|
+
if (!person)
|
|
357
|
+
return applyLocked(root, state, { ...request, enrollment }, context);
|
|
294
358
|
// Needed to hand each node its own Vault token; nodes report any gap.
|
|
295
359
|
await authorizeManager({ interactive: true }, run);
|
|
296
360
|
return applyLocked(
|
|
297
361
|
root,
|
|
298
362
|
state,
|
|
299
|
-
{ ...request, interactiveAuthorization: true },
|
|
363
|
+
{ ...request, enrollment, interactiveAuthorization: true },
|
|
300
364
|
context
|
|
301
365
|
);
|
|
302
366
|
}
|
|
@@ -317,6 +381,7 @@ function applyLocked(
|
|
|
317
381
|
...(definitionSource().kind === 'file'
|
|
318
382
|
? { definition: JSON.stringify(canonicalDefinition, null, 2) + '\n' }
|
|
319
383
|
: {}),
|
|
384
|
+
scoped: Boolean(request.node?.length),
|
|
320
385
|
});
|
|
321
386
|
return withMachineLock(root, async () => {
|
|
322
387
|
record.approve();
|
|
@@ -358,6 +423,12 @@ async function offManager(
|
|
|
358
423
|
forward: ForwardSpawn | undefined
|
|
359
424
|
): Promise<CommandOutcome> {
|
|
360
425
|
const definition = canonicalDefinition;
|
|
426
|
+
// Only the manager's fleet state knows the revision applied before.
|
|
427
|
+
if (options.rollback)
|
|
428
|
+
return actionRequired(
|
|
429
|
+
'FLEET_NOT_MANAGER',
|
|
430
|
+
`fleet-sync --rollback runs only on the manager (${definition.manager.id}); nothing was changed.`
|
|
431
|
+
);
|
|
361
432
|
const revision =
|
|
362
433
|
options.revision ??
|
|
363
434
|
packageRevision() ??
|
|
@@ -414,11 +485,42 @@ function validate(options: FleetSyncOptions): void {
|
|
|
414
485
|
'--revision requires an exact lowercase 40-character commit SHA.'
|
|
415
486
|
);
|
|
416
487
|
validateOnly(options);
|
|
488
|
+
validateRollback(options);
|
|
489
|
+
if (options.restartBusy && (options.only || options.schedule))
|
|
490
|
+
throw new FleetUsageError(
|
|
491
|
+
'--restart-busy applies to one full sync: it takes neither --only nor --schedule.'
|
|
492
|
+
);
|
|
493
|
+
validateMaintenance(options);
|
|
417
494
|
if (options.dryRun && (options.apply || options.yes))
|
|
418
495
|
throw new FleetUsageError(
|
|
419
496
|
'--dry-run cannot be combined with apply confirmation.'
|
|
420
497
|
);
|
|
421
498
|
}
|
|
499
|
+
function validateMaintenance(options: FleetSyncOptions): void {
|
|
500
|
+
if (options.force && !options.maintenanceReboot)
|
|
501
|
+
throw new FleetUsageError(
|
|
502
|
+
'--force only goes with --maintenance-reboot: it reboots a worker even while agent turns run.'
|
|
503
|
+
);
|
|
504
|
+
if (
|
|
505
|
+
options.maintenanceReboot &&
|
|
506
|
+
(options.dryRun || options.schedule || options.only)
|
|
507
|
+
)
|
|
508
|
+
throw new FleetUsageError(
|
|
509
|
+
'--maintenance-reboot reboots workers; it takes neither --dry-run, --schedule nor --only.'
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
function validateRollback(options: FleetSyncOptions): void {
|
|
513
|
+
if (!options.rollback) return;
|
|
514
|
+
if (options.revision !== undefined || options.schedule)
|
|
515
|
+
throw new FleetUsageError(
|
|
516
|
+
'--rollback chooses the revision itself: drop --revision and --schedule.'
|
|
517
|
+
);
|
|
518
|
+
if (options.only)
|
|
519
|
+
throw new FleetUsageError(
|
|
520
|
+
'--rollback converges the whole fleet; it takes no --only.'
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
422
524
|
function validateOnly(options: FleetSyncOptions): void {
|
|
423
525
|
if (options.only !== undefined && options.only !== 'accounts')
|
|
424
526
|
throw new FleetUsageError('--only takes one scope: accounts.');
|
|
@@ -450,6 +552,17 @@ async function onlyAccounts(
|
|
|
450
552
|
return syncAccounts(options, settings, { revision, run });
|
|
451
553
|
}
|
|
452
554
|
|
|
555
|
+
/** The workers that converged before this run. A fleet state written before
|
|
556
|
+
* it recorded each worker's revision counts every worker of a fleet that
|
|
557
|
+
* was applied as converged.
|
|
558
|
+
*/
|
|
559
|
+
function readyBefore(state: Enrollment | null): string[] {
|
|
560
|
+
if (state?.workers) return Object.keys(state.workers);
|
|
561
|
+
return state?.appliedRevision
|
|
562
|
+
? canonicalDefinition.workers.map((worker) => worker.id)
|
|
563
|
+
: [];
|
|
564
|
+
}
|
|
565
|
+
|
|
453
566
|
function interactive(options: FleetSyncOptions): boolean {
|
|
454
567
|
return (
|
|
455
568
|
![
|
|
@@ -516,6 +629,24 @@ function validateSchedule(schedule: unknown): void {
|
|
|
516
629
|
throw new FleetUsageError('Unknown manager schedule action.');
|
|
517
630
|
}
|
|
518
631
|
|
|
632
|
+
/** The revision a request names: --revision, or for --rollback the one
|
|
633
|
+
* applied before the approved one (null when the fleet state records none).
|
|
634
|
+
* After an approved revision that never became the applied one (its canary
|
|
635
|
+
* failed), that is the applied revision itself.
|
|
636
|
+
*/
|
|
637
|
+
function requestedRevision(
|
|
638
|
+
options: FleetSyncOptions,
|
|
639
|
+
state: Enrollment | null
|
|
640
|
+
): string | undefined | null {
|
|
641
|
+
if (!options.rollback) return options.revision;
|
|
642
|
+
if (
|
|
643
|
+
state?.appliedRevision &&
|
|
644
|
+
state.appliedRevision !== state.approvedRevision
|
|
645
|
+
)
|
|
646
|
+
return state.appliedRevision;
|
|
647
|
+
return state?.previousRevision ?? null;
|
|
648
|
+
}
|
|
649
|
+
|
|
519
650
|
async function selectedRevision(
|
|
520
651
|
explicit: string | undefined,
|
|
521
652
|
approved: string | undefined,
|
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
4
|
import {
|
|
5
5
|
DefinitionError,
|
|
6
6
|
parseDefinition,
|
|
7
|
+
sshTarget,
|
|
7
8
|
type DefinitionProblem,
|
|
8
9
|
type FleetDefinition,
|
|
9
10
|
} from '../definition';
|
|
10
11
|
import { FleetUsageError } from '../errors';
|
|
11
12
|
import { PACKAGE_ROOT } from '../package-info';
|
|
12
|
-
import { ansibleCommand } from '../platform/ansible';
|
|
13
|
+
import { ansibleCommand, ansibleConfig } from '../platform/ansible';
|
|
14
|
+
import { shellQuote } from '../ssh';
|
|
15
|
+
import {
|
|
16
|
+
connectionOptions,
|
|
17
|
+
declaredConnection,
|
|
18
|
+
knownHostsPath,
|
|
19
|
+
SSH_TRANSPORT,
|
|
20
|
+
sshOptionArgs,
|
|
21
|
+
} from '../ssh-connection';
|
|
22
|
+
import {
|
|
23
|
+
expandRecords,
|
|
24
|
+
runRecordCommand,
|
|
25
|
+
type RecordCommand,
|
|
26
|
+
} from '../worker-records';
|
|
13
27
|
import { runCommand } from '../runner';
|
|
14
28
|
import type { Check, Runner } from '../types';
|
|
15
29
|
import type { CommandOutcome } from './contracts';
|
|
@@ -26,6 +40,8 @@ export interface FleetValidateServices {
|
|
|
26
40
|
platform: NodeJS.Platform;
|
|
27
41
|
/** The package's Ansible playbooks, relative to `root`. */
|
|
28
42
|
root: string;
|
|
43
|
+
/** Runs a command `workerRecords` names. */
|
|
44
|
+
records?: RecordCommand;
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
const SERVICES: FleetValidateServices = {
|
|
@@ -42,20 +58,34 @@ const PLAYBOOK_DIRECTORIES = ['ansible/ubuntu', 'ansible/macos'];
|
|
|
42
58
|
* worker in the `workers` group in the order it is declared. Host names are
|
|
43
59
|
* the fleet ids; a machine is reached as fleet-sync reaches it.
|
|
44
60
|
*/
|
|
45
|
-
export function fleetInventory(
|
|
61
|
+
export function fleetInventory(
|
|
62
|
+
definition: FleetDefinition,
|
|
63
|
+
knownHosts = knownHostsPath()
|
|
64
|
+
): unknown {
|
|
46
65
|
const workers = Object.fromEntries(
|
|
47
|
-
definition.workers.map((worker) =>
|
|
48
|
-
worker
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
...
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
definition.workers.map((worker) => {
|
|
67
|
+
const declared = declaredConnection(worker);
|
|
68
|
+
// The same options a direct session takes (src/ssh-connection.ts).
|
|
69
|
+
const options = [
|
|
70
|
+
...SSH_TRANSPORT,
|
|
71
|
+
...(declared ? connectionOptions(declared, knownHosts) : []),
|
|
72
|
+
];
|
|
73
|
+
return [
|
|
74
|
+
worker.id,
|
|
75
|
+
{
|
|
76
|
+
ansible_host: sshTarget(worker),
|
|
77
|
+
...(worker.network
|
|
78
|
+
? {
|
|
79
|
+
ansible_user: worker.network.user,
|
|
80
|
+
ansible_port: worker.network.sshPort,
|
|
81
|
+
}
|
|
82
|
+
: {}),
|
|
83
|
+
ansible_ssh_common_args: sshOptionArgs(options)
|
|
84
|
+
.map((arg) => (arg === '-o' ? arg : shellQuote(arg)))
|
|
85
|
+
.join(' '),
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
})
|
|
59
89
|
);
|
|
60
90
|
return {
|
|
61
91
|
all: {
|
|
@@ -89,7 +119,13 @@ export async function fleetValidate(
|
|
|
89
119
|
);
|
|
90
120
|
let definition: FleetDefinition;
|
|
91
121
|
try {
|
|
92
|
-
definition = parseDefinition(
|
|
122
|
+
definition = parseDefinition(
|
|
123
|
+
expandRecords(
|
|
124
|
+
await readJson(path),
|
|
125
|
+
dirname(path),
|
|
126
|
+
services.records ?? runRecordCommand
|
|
127
|
+
)
|
|
128
|
+
);
|
|
93
129
|
} catch (error) {
|
|
94
130
|
if (error instanceof DefinitionError) return invalid(path, error.problem);
|
|
95
131
|
throw error;
|
|
@@ -164,14 +200,16 @@ async function ansibleChecks(
|
|
|
164
200
|
const directory = await mkdtemp(join(tmpdir(), 'devfleet-validate-'));
|
|
165
201
|
const inventory = join(directory, 'inventory.json');
|
|
166
202
|
try {
|
|
167
|
-
await writeFile(
|
|
168
|
-
|
|
169
|
-
|
|
203
|
+
await writeFile(
|
|
204
|
+
inventory,
|
|
205
|
+
JSON.stringify(fleetInventory(definition, knownHostsPath(services.env))),
|
|
206
|
+
{ mode: 0o600 }
|
|
207
|
+
);
|
|
170
208
|
const playbook = ansibleCommand(services.platform, services.root);
|
|
171
209
|
const tool = (name: string) => [...playbook.slice(0, -1), name];
|
|
172
210
|
const listed = await services.run(
|
|
173
211
|
[...tool('ansible-inventory'), '-i', inventory, '--list'],
|
|
174
|
-
{ timeoutMs: 120_000 }
|
|
212
|
+
{ timeoutMs: 120_000, env: ansibleConfig(services.root) }
|
|
175
213
|
);
|
|
176
214
|
const checks: Check[] = [
|
|
177
215
|
listed.ok
|
|
@@ -200,7 +238,7 @@ async function ansibleChecks(
|
|
|
200
238
|
'--syntax-check',
|
|
201
239
|
join(services.root, file),
|
|
202
240
|
],
|
|
203
|
-
{ timeoutMs: 120_000 }
|
|
241
|
+
{ timeoutMs: 120_000, env: ansibleConfig(services.root) }
|
|
204
242
|
);
|
|
205
243
|
if (!result.ok) failed.push(file);
|
|
206
244
|
}
|
package/src/cli/fleet-watch.ts
CHANGED
|
@@ -35,6 +35,10 @@ export interface FleetWatchServices {
|
|
|
35
35
|
secrets: SecretsProvider;
|
|
36
36
|
send: typeof fetch;
|
|
37
37
|
now: () => Date;
|
|
38
|
+
/** Parks the workers `worker:idle` warns about; only when fleet.json turns
|
|
39
|
+
* parking.autoPark on. One check per worker it parked or could not.
|
|
40
|
+
*/
|
|
41
|
+
autoPark?: (reports: NodeReport[]) => Promise<Check[]>;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
/** Compares the fleet's health with the previous run and notifies on
|
|
@@ -61,7 +65,8 @@ export async function fleetWatch(
|
|
|
61
65
|
const previous = services.store
|
|
62
66
|
? parseMemory(await services.store.read().catch(() => null))
|
|
63
67
|
: null;
|
|
64
|
-
const
|
|
68
|
+
const reports = await services.status();
|
|
69
|
+
const observed = observe(reports);
|
|
65
70
|
const { notifications, memory } = transitions(
|
|
66
71
|
previous,
|
|
67
72
|
observed,
|
|
@@ -80,6 +85,7 @@ export async function fleetWatch(
|
|
|
80
85
|
))
|
|
81
86
|
);
|
|
82
87
|
if (!notify) checks.push(unconfigured('warn'));
|
|
88
|
+
if (services.autoPark) checks.push(...(await services.autoPark(reports)));
|
|
83
89
|
checks.push(await heartbeat(services.store, JSON.stringify(memory)));
|
|
84
90
|
return outcome(checks, notifications);
|
|
85
91
|
}
|
package/src/cli/fleet.ts
CHANGED
|
@@ -16,13 +16,37 @@ import { probeSource } from './probe-source';
|
|
|
16
16
|
import { executeRepairCommand } from './repair';
|
|
17
17
|
import { scheduleCheck } from '../schedule-check';
|
|
18
18
|
import { readHistory } from '../state/history';
|
|
19
|
+
import { withUsage } from '../worker-activity';
|
|
20
|
+
import { activeNodes, appendUsage } from './worker-usage-report';
|
|
21
|
+
import { fleet, secret } from '../fleet-config';
|
|
22
|
+
import { appendTailnetLeftovers } from '../tailnet-cleanup';
|
|
23
|
+
import { fleetTailnetControl, type TailnetControl } from '../tailnet-control';
|
|
24
|
+
import { packageInfo } from '../package-info';
|
|
25
|
+
import { managerRuns } from './fleet-sync-checkout';
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
19
28
|
|
|
20
29
|
export interface FleetServices {
|
|
21
30
|
run: Runner;
|
|
22
31
|
source: () => Promise<string>;
|
|
32
|
+
/** The tailnet control plane fleet.json declares, asked for devices
|
|
33
|
+
* tagged as workers that no declared worker accounts for. Absent, or null,
|
|
34
|
+
* fleet-status says nothing about them.
|
|
35
|
+
*/
|
|
36
|
+
tailnet?: () => Promise<TailnetControl | null>;
|
|
37
|
+
/** The commit the running package names; absent or null in a checkout. */
|
|
38
|
+
revision?: string | null;
|
|
39
|
+
/** Where the manager keeps its fleet-owned checkout of each revision. */
|
|
40
|
+
runs?: string;
|
|
23
41
|
}
|
|
24
42
|
|
|
25
|
-
const SERVICES: FleetServices = {
|
|
43
|
+
const SERVICES: FleetServices = {
|
|
44
|
+
run: runCommand,
|
|
45
|
+
source: probeSource,
|
|
46
|
+
tailnet: () => fleetTailnetControl(fleet.tailnet, secret),
|
|
47
|
+
revision: packageInfo().revision,
|
|
48
|
+
runs: managerRuns(),
|
|
49
|
+
};
|
|
26
50
|
|
|
27
51
|
export async function executeFleet(
|
|
28
52
|
command: string,
|
|
@@ -114,11 +138,14 @@ async function diagnose(
|
|
|
114
138
|
settings: Settings & { missingRoster: boolean },
|
|
115
139
|
services: FleetServices
|
|
116
140
|
): Promise<CommandOutcome> {
|
|
117
|
-
|
|
141
|
+
// A parked worker is not contacted; it is listed as parked.
|
|
142
|
+
const active = activeNodes(nodes);
|
|
143
|
+
const source = active.length ? await services.source() : undefined;
|
|
144
|
+
const root = statusRoot(settings, services);
|
|
118
145
|
const reports = await doctor(
|
|
119
|
-
|
|
146
|
+
active,
|
|
120
147
|
{
|
|
121
|
-
root
|
|
148
|
+
root,
|
|
122
149
|
local: options.local === true,
|
|
123
150
|
required: capabilities(options.require),
|
|
124
151
|
timeoutMs: timeout(options),
|
|
@@ -126,11 +153,23 @@ async function diagnose(
|
|
|
126
153
|
},
|
|
127
154
|
services.run
|
|
128
155
|
);
|
|
156
|
+
explainMissingCheckout(reports, root);
|
|
129
157
|
if (!options.local && settings.missingRoster) reports.push(missingRoster());
|
|
130
158
|
const schedule = scheduleCheck(readHistory(), new Date());
|
|
131
159
|
if (schedule) reports[0]?.checks.push(schedule);
|
|
160
|
+
// What each worker is doing, unless only this manager is checked.
|
|
161
|
+
const usage = await usageOf(
|
|
162
|
+
reports,
|
|
163
|
+
active,
|
|
164
|
+
services.run,
|
|
165
|
+
skipsFleet(options, settings)
|
|
166
|
+
);
|
|
167
|
+
// Devices tagged as workers that are no declared worker (#123).
|
|
168
|
+
if (!skipsFleet(options, settings))
|
|
169
|
+
await appendTailnetLeftovers(reports, fleet, services.tailnet, nodes);
|
|
132
170
|
const report = {
|
|
133
171
|
...makeReport('doctor', reports),
|
|
172
|
+
...(usage ?? {}),
|
|
134
173
|
coverage: {
|
|
135
174
|
scope: options.local
|
|
136
175
|
? 'local'
|
|
@@ -146,10 +185,53 @@ async function diagnose(
|
|
|
146
185
|
return {
|
|
147
186
|
exitCode: reportExitCode(report, options.strict === true),
|
|
148
187
|
data: report,
|
|
149
|
-
text: renderReport(report),
|
|
188
|
+
text: withUsage(renderReport(report), usage),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** What the manager and the workers are measured against. The published
|
|
193
|
+
* package holds no Git history and no bun.lock, and whatever repository
|
|
194
|
+
* surrounds it is the consumer's project: from a package it is the
|
|
195
|
+
* fleet-owned checkout fleet-sync made of the commit the package names. A
|
|
196
|
+
* checkout (development) is its own reference.
|
|
197
|
+
*/
|
|
198
|
+
function statusRoot(settings: Settings, services: FleetServices): string {
|
|
199
|
+
const { revision, runs } = services;
|
|
200
|
+
return revision && runs ? join(runs, revision) : settings.workspace;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The manager has not run fleet-sync at this package's commit yet (a
|
|
204
|
+
* checkout is its own reference, so it always exists).
|
|
205
|
+
*/
|
|
206
|
+
function explainMissingCheckout(reports: NodeReport[], root: string): void {
|
|
207
|
+
if (existsSync(root)) return;
|
|
208
|
+
const check = reports[0]?.checks.find((item) => item.id === 'workspace');
|
|
209
|
+
if (!check) return;
|
|
210
|
+
check.summary =
|
|
211
|
+
'This manager has no fleet-owned checkout of the commit this package names; it is made by fleet-sync.';
|
|
212
|
+
check.remedy = {
|
|
213
|
+
action:
|
|
214
|
+
'Run devfleet fleet-sync from the project that holds your fleet.json, then fleet-status again.',
|
|
150
215
|
};
|
|
151
216
|
}
|
|
152
217
|
|
|
218
|
+
/** Only this manager is checked: what is said of the fleet is skipped. */
|
|
219
|
+
function skipsFleet(
|
|
220
|
+
options: CliOptions,
|
|
221
|
+
settings: { missingRoster: boolean }
|
|
222
|
+
): boolean {
|
|
223
|
+
return Boolean(options.local || settings.missingRoster);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function usageOf(
|
|
227
|
+
reports: NodeReport[],
|
|
228
|
+
nodes: FleetNode[],
|
|
229
|
+
run: Runner,
|
|
230
|
+
skip: boolean
|
|
231
|
+
) {
|
|
232
|
+
return skip ? Promise.resolve(null) : appendUsage(reports, nodes, { run });
|
|
233
|
+
}
|
|
234
|
+
|
|
153
235
|
function missingRoster(): NodeReport {
|
|
154
236
|
return {
|
|
155
237
|
node: 'fleet',
|
|
@@ -3,6 +3,7 @@ import { verifyT3Endpoint } from '../t3-endpoint';
|
|
|
3
3
|
import type { Check, NodeReport, Runner } from '../types';
|
|
4
4
|
import { fleet as definition } from '../fleet-config';
|
|
5
5
|
import { enrolledRoster } from '../definition';
|
|
6
|
+
import { readParked } from '../state/workers';
|
|
6
7
|
import type { Tailnet } from '../tailscale';
|
|
7
8
|
import type { FleetNode } from '../types';
|
|
8
9
|
import {
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
} from '../monitoring/beszel';
|
|
13
14
|
import { hubAccess } from '../monitoring/hub-access';
|
|
14
15
|
import { hubUrl } from '../topology';
|
|
16
|
+
import { leftoverSystems, listSystems } from '../monitoring/systems';
|
|
15
17
|
|
|
16
18
|
export interface DashboardAccess {
|
|
17
19
|
(hub: string): Promise<{ request: BeszelRequest }>;
|
|
@@ -76,6 +78,7 @@ async function nodeEvidence(
|
|
|
76
78
|
) ?? dashboard;
|
|
77
79
|
report.checks.push(...evidence.checks);
|
|
78
80
|
}
|
|
81
|
+
dashboard.checks.push(...(await leftovers(request)));
|
|
79
82
|
} catch (error) {
|
|
80
83
|
dashboard.checks.push({
|
|
81
84
|
id: 'beszel:access',
|
|
@@ -92,6 +95,21 @@ async function nodeEvidence(
|
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
97
|
|
|
98
|
+
/** Systems the hub watches that no declared worker (parked ones included)
|
|
99
|
+
* and not the manager is named after. A hub whose list cannot be read says
|
|
100
|
+
* nothing here; its access is reported above.
|
|
101
|
+
*/
|
|
102
|
+
async function leftovers(request: BeszelRequest): Promise<Check[]> {
|
|
103
|
+
try {
|
|
104
|
+
return leftoverSystems(await listSystems(request), [
|
|
105
|
+
definition.manager.id,
|
|
106
|
+
...definition.workers.map((worker) => worker.id),
|
|
107
|
+
]);
|
|
108
|
+
} catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
95
113
|
async function hubReachable(
|
|
96
114
|
hub: string,
|
|
97
115
|
fetcher: typeof fetch
|
|
@@ -155,7 +173,13 @@ export function managedHealthScope(
|
|
|
155
173
|
roster: FleetNode[],
|
|
156
174
|
includeManager = true
|
|
157
175
|
) {
|
|
158
|
-
|
|
176
|
+
// A parked worker is off on purpose: it is not audited, not missing and
|
|
177
|
+
// its device is not expected online (fleet-status lists it as parked).
|
|
178
|
+
const parked = readParked();
|
|
179
|
+
const canonical = {
|
|
180
|
+
...definition,
|
|
181
|
+
workers: definition.workers.filter((entry) => !parked[entry.id]),
|
|
182
|
+
};
|
|
159
183
|
const declared = roster.filter((node) =>
|
|
160
184
|
canonical.workers.some((entry) => entry.id === node.id)
|
|
161
185
|
);
|