@crvouga/dev-fleet 0.7.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 +88 -7
- 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 +83 -0
- package/ansible/macos/manager.yml +164 -9
- 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/macos/worker-host-key.yml +33 -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 +65 -0
- 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 +21 -11
- package/docs/consumer-setup.md +74 -58
- package/docs/convergence.md +67 -0
- package/docs/declared.md +61 -34
- package/docs/machine-setup.md +73 -7
- package/docs/manager-setup.md +34 -0
- package/docs/operations.md +64 -9
- package/docs/releasing.md +49 -1
- package/docs/runbooks.md +112 -7
- package/docs/state.md +23 -11
- package/docs/support-matrix.md +40 -4
- package/docs/topology.md +82 -12
- package/docs/ubuntu-sync.md +98 -42
- 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 +1991 -162
- package/schema/check-ids.schema.json +34 -0
- package/schema/fleet.schema.json +349 -10
- 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 +62 -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 +108 -0
- package/src/cli/fleet-node-lifecycle.ts +704 -0
- package/src/cli/fleet-node-park.ts +238 -0
- package/src/cli/fleet-node-seed.ts +167 -0
- 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 +6 -0
- package/src/cli/fleet-sync-manager.ts +123 -8
- 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 +172 -15
- package/src/cli/fleet-sync-schedule.ts +14 -4
- package/src/cli/fleet-sync-state.ts +31 -10
- package/src/cli/fleet-sync-vault.ts +152 -26
- package/src/cli/fleet-sync-worker.ts +214 -0
- package/src/cli/fleet-sync.ts +172 -21
- package/src/cli/fleet-validate.ts +277 -0
- 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 +251 -4
- package/src/cli/registry.ts +47 -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 +762 -232
- 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 +70 -16
- 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 +89 -8
- 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 +7 -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-credentials.ts +201 -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 +224 -0
- package/src/worker-parking.ts +70 -0
- package/src/worker-policy.ts +38 -36
- 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 +143 -0
- package/src/worker-sizing.ts +76 -0
- package/src/cli/schedule-logs.ts +0 -36
package/src/cli/fleet-sync.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ManagerReport } from '../manager-sync';
|
|
1
2
|
import { localCheckoutRevision } from '../machine/local-source';
|
|
2
3
|
import {
|
|
3
4
|
definitionSource,
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
import { managerFailure } from '../manager-failure';
|
|
8
9
|
import { managerText, stdoutColor } from './fleet-sync-output';
|
|
9
10
|
import { managerSchedule, type ScheduleAction } from './fleet-sync-schedule';
|
|
10
|
-
import { enrollManager } from './fleet-sync-enrollment';
|
|
11
|
+
import { enrollManager, type EnrollmentResult } from './fleet-sync-enrollment';
|
|
11
12
|
import { authorizeManager } from './fleet-sync-vault';
|
|
12
13
|
import { homedir } from 'node:os';
|
|
13
14
|
import { join } from 'node:path';
|
|
@@ -19,14 +20,14 @@ import {
|
|
|
19
20
|
type Enrollment,
|
|
20
21
|
} from '../machine/state';
|
|
21
22
|
import { loadEnrollment } from '../state/enrollment';
|
|
22
|
-
import { legacyHome } from '../state/paths';
|
|
23
|
+
import { legacyHome, stateRoot } from '../state/paths';
|
|
24
|
+
import { stateDefinition } from '../state/operator';
|
|
23
25
|
import {
|
|
24
26
|
appendRun,
|
|
25
27
|
summarizeRun,
|
|
26
28
|
type SyncRun,
|
|
27
29
|
type Trigger,
|
|
28
30
|
} from '../state/history';
|
|
29
|
-
import { capScheduleLogs } from './schedule-logs';
|
|
30
31
|
import {
|
|
31
32
|
DIVERGED_REMEDY,
|
|
32
33
|
mutateState,
|
|
@@ -57,12 +58,22 @@ import {
|
|
|
57
58
|
|
|
58
59
|
export interface FleetSyncOptions {
|
|
59
60
|
revision?: string;
|
|
61
|
+
/** Converge to the revision applied before the current one. */
|
|
62
|
+
rollback?: boolean;
|
|
60
63
|
dryRun?: boolean;
|
|
61
64
|
apply?: boolean;
|
|
62
65
|
yes?: boolean;
|
|
63
66
|
json?: boolean;
|
|
64
67
|
nonInteractive?: boolean;
|
|
65
68
|
node?: string[];
|
|
69
|
+
/** The worker to prove a revision new to the fleet on first, when it is
|
|
70
|
+
* reachable; overrides `canary` in fleet.json.
|
|
71
|
+
*/
|
|
72
|
+
canary?: string;
|
|
73
|
+
/** Opt-in: a worker without a token of its own may be logged in with the
|
|
74
|
+
* fleet's shared Vault user, typed on its own terminal.
|
|
75
|
+
*/
|
|
76
|
+
vaultPasswordLogin?: boolean;
|
|
66
77
|
all?: boolean;
|
|
67
78
|
schedule?: ScheduleAction;
|
|
68
79
|
/** `accounts`: only the account-derived configuration, on every node. */
|
|
@@ -76,6 +87,23 @@ export interface FleetSyncOptions {
|
|
|
76
87
|
expectedPlanFingerprint?: string;
|
|
77
88
|
/** Take the manager lease from another manager before it expired. */
|
|
78
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[];
|
|
79
107
|
}
|
|
80
108
|
export function machineRoot(): string {
|
|
81
109
|
const root = join(homedir(), '.local/share/personal/fleet/manager');
|
|
@@ -105,7 +133,6 @@ export async function fleetSync(
|
|
|
105
133
|
});
|
|
106
134
|
if (options.dryRun || options.schedule) return sync();
|
|
107
135
|
const trigger = runTrigger();
|
|
108
|
-
if (trigger === 'schedule') capScheduleLogs(join(machineRoot(), 'logs'));
|
|
109
136
|
const startedAt = new Date();
|
|
110
137
|
const outcome = await sync();
|
|
111
138
|
await recordRun(
|
|
@@ -177,17 +204,8 @@ export async function managerSync(
|
|
|
177
204
|
): Promise<CommandOutcome> {
|
|
178
205
|
await ensurePrivateRoot(root);
|
|
179
206
|
const state = await loadEnrollment(legacyHome() ? root : null);
|
|
180
|
-
const revision = await
|
|
181
|
-
|
|
182
|
-
state?.approvedRevision,
|
|
183
|
-
settings,
|
|
184
|
-
run
|
|
185
|
-
);
|
|
186
|
-
if (!isRevision(revision))
|
|
187
|
-
return actionRequired(
|
|
188
|
-
'FLEET_REVISION_REQUIRED',
|
|
189
|
-
'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.'
|
|
190
|
-
);
|
|
207
|
+
const revision = await managerRevision(options, state, settings, run);
|
|
208
|
+
if (typeof revision !== 'string') return revision;
|
|
191
209
|
if (options.schedule)
|
|
192
210
|
return scheduleRequest(options, revision, settings, run);
|
|
193
211
|
const rehomed = await rehome(
|
|
@@ -229,6 +247,33 @@ export async function managerSync(
|
|
|
229
247
|
});
|
|
230
248
|
}
|
|
231
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
|
+
|
|
232
277
|
/** The preview: the read-only report of every machine, and the plan of what
|
|
233
278
|
* a sync would change on them.
|
|
234
279
|
*/
|
|
@@ -253,6 +298,11 @@ async function preview(
|
|
|
253
298
|
.map((node) => node.node)
|
|
254
299
|
.filter((node): node is string => typeof node === 'string'),
|
|
255
300
|
sources: planSources(settings.workspace, context.root),
|
|
301
|
+
restartBusy: options.restartBusy === true,
|
|
302
|
+
definitions: {
|
|
303
|
+
applied: appliedDefinition(),
|
|
304
|
+
selected: canonicalDefinition,
|
|
305
|
+
},
|
|
256
306
|
},
|
|
257
307
|
context.run
|
|
258
308
|
);
|
|
@@ -263,6 +313,16 @@ async function preview(
|
|
|
263
313
|
};
|
|
264
314
|
}
|
|
265
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
|
+
|
|
266
326
|
interface ApplyContext {
|
|
267
327
|
settings: Settings;
|
|
268
328
|
run: Runner;
|
|
@@ -279,15 +339,28 @@ async function authorizeAndApply(
|
|
|
279
339
|
context: ApplyContext
|
|
280
340
|
): Promise<CommandOutcome> {
|
|
281
341
|
const { settings, run, revision } = context;
|
|
282
|
-
const request = {
|
|
283
|
-
|
|
284
|
-
|
|
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);
|
|
285
358
|
// Needed to hand each node its own Vault token; nodes report any gap.
|
|
286
359
|
await authorizeManager({ interactive: true }, run);
|
|
287
360
|
return applyLocked(
|
|
288
361
|
root,
|
|
289
362
|
state,
|
|
290
|
-
{ ...request, interactiveAuthorization: true },
|
|
363
|
+
{ ...request, enrollment, interactiveAuthorization: true },
|
|
291
364
|
context
|
|
292
365
|
);
|
|
293
366
|
}
|
|
@@ -308,6 +381,7 @@ function applyLocked(
|
|
|
308
381
|
...(definitionSource().kind === 'file'
|
|
309
382
|
? { definition: JSON.stringify(canonicalDefinition, null, 2) + '\n' }
|
|
310
383
|
: {}),
|
|
384
|
+
scoped: Boolean(request.node?.length),
|
|
311
385
|
});
|
|
312
386
|
return withMachineLock(root, async () => {
|
|
313
387
|
record.approve();
|
|
@@ -316,7 +390,7 @@ function applyLocked(
|
|
|
316
390
|
{ ...request, rolledOut: state?.appliedRevision === revision },
|
|
317
391
|
settings,
|
|
318
392
|
run,
|
|
319
|
-
(report) => record.settle(report.ok)
|
|
393
|
+
(report) => record.settle(report.ok, appliedRevisions(report))
|
|
320
394
|
);
|
|
321
395
|
} finally {
|
|
322
396
|
await record.abandon();
|
|
@@ -349,6 +423,12 @@ async function offManager(
|
|
|
349
423
|
forward: ForwardSpawn | undefined
|
|
350
424
|
): Promise<CommandOutcome> {
|
|
351
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
|
+
);
|
|
352
432
|
const revision =
|
|
353
433
|
options.revision ??
|
|
354
434
|
packageRevision() ??
|
|
@@ -356,7 +436,7 @@ async function offManager(
|
|
|
356
436
|
if (forwardingDisabled())
|
|
357
437
|
return actionRequired(
|
|
358
438
|
'FLEET_NOT_MANAGER',
|
|
359
|
-
`fleet-sync runs only on the manager (${definition.manager.id}); nothing was changed. Run it there, or from a worker that can SSH to the manager.`,
|
|
439
|
+
`fleet-sync runs only on the manager (${definition.manager.id}), which is always a macOS machine; nothing was changed. Run it there, or from a worker that can SSH to the manager.`,
|
|
360
440
|
revision ?? null
|
|
361
441
|
);
|
|
362
442
|
const runScript = revision
|
|
@@ -405,11 +485,42 @@ function validate(options: FleetSyncOptions): void {
|
|
|
405
485
|
'--revision requires an exact lowercase 40-character commit SHA.'
|
|
406
486
|
);
|
|
407
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);
|
|
408
494
|
if (options.dryRun && (options.apply || options.yes))
|
|
409
495
|
throw new FleetUsageError(
|
|
410
496
|
'--dry-run cannot be combined with apply confirmation.'
|
|
411
497
|
);
|
|
412
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
|
+
|
|
413
524
|
function validateOnly(options: FleetSyncOptions): void {
|
|
414
525
|
if (options.only !== undefined && options.only !== 'accounts')
|
|
415
526
|
throw new FleetUsageError('--only takes one scope: accounts.');
|
|
@@ -441,6 +552,17 @@ async function onlyAccounts(
|
|
|
441
552
|
return syncAccounts(options, settings, { revision, run });
|
|
442
553
|
}
|
|
443
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
|
+
|
|
444
566
|
function interactive(options: FleetSyncOptions): boolean {
|
|
445
567
|
return (
|
|
446
568
|
![
|
|
@@ -507,6 +629,24 @@ function validateSchedule(schedule: unknown): void {
|
|
|
507
629
|
throw new FleetUsageError('Unknown manager schedule action.');
|
|
508
630
|
}
|
|
509
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
|
+
|
|
510
650
|
async function selectedRevision(
|
|
511
651
|
explicit: string | undefined,
|
|
512
652
|
approved: string | undefined,
|
|
@@ -520,3 +660,14 @@ async function selectedRevision(
|
|
|
520
660
|
approved
|
|
521
661
|
);
|
|
522
662
|
}
|
|
663
|
+
|
|
664
|
+
/** The revision each worker holds after this run, for the fleet state: a
|
|
665
|
+
* worker that converged keeps its progress even when the fleet is not ready.
|
|
666
|
+
*/
|
|
667
|
+
function appliedRevisions(report: ManagerReport): Record<string, string> {
|
|
668
|
+
return Object.fromEntries(
|
|
669
|
+
report.nodes
|
|
670
|
+
.filter((node) => node.role !== 'manager' && node.appliedRevision)
|
|
671
|
+
.map((node) => [node.node, node.appliedRevision!])
|
|
672
|
+
);
|
|
673
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
DefinitionError,
|
|
6
|
+
parseDefinition,
|
|
7
|
+
sshTarget,
|
|
8
|
+
type DefinitionProblem,
|
|
9
|
+
type FleetDefinition,
|
|
10
|
+
} from '../definition';
|
|
11
|
+
import { FleetUsageError } from '../errors';
|
|
12
|
+
import { PACKAGE_ROOT } from '../package-info';
|
|
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';
|
|
27
|
+
import { runCommand } from '../runner';
|
|
28
|
+
import type { Check, Runner } from '../types';
|
|
29
|
+
import type { CommandOutcome } from './contracts';
|
|
30
|
+
|
|
31
|
+
export interface FleetValidateOptions {
|
|
32
|
+
file?: string;
|
|
33
|
+
ansible?: boolean;
|
|
34
|
+
json?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FleetValidateServices {
|
|
38
|
+
run: Runner;
|
|
39
|
+
env: NodeJS.ProcessEnv;
|
|
40
|
+
platform: NodeJS.Platform;
|
|
41
|
+
/** The package's Ansible playbooks, relative to `root`. */
|
|
42
|
+
root: string;
|
|
43
|
+
/** Runs a command `workerRecords` names. */
|
|
44
|
+
records?: RecordCommand;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SERVICES: FleetValidateServices = {
|
|
48
|
+
run: runCommand,
|
|
49
|
+
env: process.env,
|
|
50
|
+
platform: process.platform,
|
|
51
|
+
root: PACKAGE_ROOT,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The playbooks a definition must be able to drive, in the package. */
|
|
55
|
+
const PLAYBOOK_DIRECTORIES = ['ansible/ubuntu', 'ansible/macos'];
|
|
56
|
+
|
|
57
|
+
/** The Ansible inventory a definition describes: the manager, and every
|
|
58
|
+
* worker in the `workers` group in the order it is declared. Host names are
|
|
59
|
+
* the fleet ids; a machine is reached as fleet-sync reaches it.
|
|
60
|
+
*/
|
|
61
|
+
export function fleetInventory(
|
|
62
|
+
definition: FleetDefinition,
|
|
63
|
+
knownHosts = knownHostsPath()
|
|
64
|
+
): unknown {
|
|
65
|
+
const workers = Object.fromEntries(
|
|
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
|
+
})
|
|
89
|
+
);
|
|
90
|
+
return {
|
|
91
|
+
all: {
|
|
92
|
+
children: {
|
|
93
|
+
manager: {
|
|
94
|
+
hosts: {
|
|
95
|
+
[definition.manager.id]: {
|
|
96
|
+
ansible_connection: 'local',
|
|
97
|
+
ansible_user: definition.manager.network.user,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
workers: { hosts: workers },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Reads and judges one fleet.json. Nothing is contacted: with `--ansible`
|
|
108
|
+
* the pinned Ansible renders the inventory and checks the playbooks' syntax
|
|
109
|
+
* on this machine.
|
|
110
|
+
*/
|
|
111
|
+
export async function fleetValidate(
|
|
112
|
+
options: FleetValidateOptions,
|
|
113
|
+
services: FleetValidateServices = SERVICES
|
|
114
|
+
): Promise<CommandOutcome> {
|
|
115
|
+
const path = options.file ?? services.env['DEVFLEET_CONFIG'];
|
|
116
|
+
if (!path)
|
|
117
|
+
throw new FleetUsageError(
|
|
118
|
+
'No fleet configuration to validate. Run devfleet from the project that holds your fleet.json, or pass --file PATH.'
|
|
119
|
+
);
|
|
120
|
+
let definition: FleetDefinition;
|
|
121
|
+
try {
|
|
122
|
+
definition = parseDefinition(
|
|
123
|
+
expandRecords(
|
|
124
|
+
await readJson(path),
|
|
125
|
+
dirname(path),
|
|
126
|
+
services.records ?? runRecordCommand
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error instanceof DefinitionError) return invalid(path, error.problem);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
const checks: Check[] = [
|
|
134
|
+
{
|
|
135
|
+
id: 'definition:schema',
|
|
136
|
+
status: 'pass',
|
|
137
|
+
summary: `${path} is a valid fleet definition: a manager and ${definition.workers.length} worker(s).`,
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
if (options.ansible)
|
|
141
|
+
checks.push(...(await ansibleChecks(definition, services)));
|
|
142
|
+
const ok = checks.every((check) => check.status !== 'fail');
|
|
143
|
+
return {
|
|
144
|
+
exitCode: ok ? 0 : 1,
|
|
145
|
+
data: { version: 1, command: 'fleet-validate', ok, path, checks },
|
|
146
|
+
text: checks
|
|
147
|
+
.map(
|
|
148
|
+
(check) => `${check.status.toUpperCase()} ${check.id}: ${check.summary}`
|
|
149
|
+
)
|
|
150
|
+
.join('\n'),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function readJson(path: string): Promise<unknown> {
|
|
155
|
+
let text: string;
|
|
156
|
+
try {
|
|
157
|
+
text = await readFile(path, 'utf8');
|
|
158
|
+
} catch {
|
|
159
|
+
throw new DefinitionError({
|
|
160
|
+
code: 'DEFINITION_UNREADABLE',
|
|
161
|
+
path: '$',
|
|
162
|
+
expected: 'a readable file',
|
|
163
|
+
actual: 'no file',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(text) as unknown;
|
|
168
|
+
} catch {
|
|
169
|
+
throw new DefinitionError({
|
|
170
|
+
code: 'DEFINITION_UNREADABLE',
|
|
171
|
+
path: '$',
|
|
172
|
+
expected: 'JSON',
|
|
173
|
+
actual: 'text that is not JSON',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function invalid(path: string, problem: DefinitionProblem): CommandOutcome {
|
|
179
|
+
const data = {
|
|
180
|
+
version: 1,
|
|
181
|
+
command: 'fleet-validate',
|
|
182
|
+
ok: false,
|
|
183
|
+
path,
|
|
184
|
+
problem,
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
exitCode: 1,
|
|
188
|
+
data,
|
|
189
|
+
text: `${problem.code} at ${problem.path}: expected ${problem.expected}, found ${problem.actual}.`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Renders the inventory, lists it with `ansible-inventory` and checks the
|
|
194
|
+
* syntax of every playbook against it.
|
|
195
|
+
*/
|
|
196
|
+
async function ansibleChecks(
|
|
197
|
+
definition: FleetDefinition,
|
|
198
|
+
services: FleetValidateServices
|
|
199
|
+
): Promise<Check[]> {
|
|
200
|
+
const directory = await mkdtemp(join(tmpdir(), 'devfleet-validate-'));
|
|
201
|
+
const inventory = join(directory, 'inventory.json');
|
|
202
|
+
try {
|
|
203
|
+
await writeFile(
|
|
204
|
+
inventory,
|
|
205
|
+
JSON.stringify(fleetInventory(definition, knownHostsPath(services.env))),
|
|
206
|
+
{ mode: 0o600 }
|
|
207
|
+
);
|
|
208
|
+
const playbook = ansibleCommand(services.platform, services.root);
|
|
209
|
+
const tool = (name: string) => [...playbook.slice(0, -1), name];
|
|
210
|
+
const listed = await services.run(
|
|
211
|
+
[...tool('ansible-inventory'), '-i', inventory, '--list'],
|
|
212
|
+
{ timeoutMs: 120_000, env: ansibleConfig(services.root) }
|
|
213
|
+
);
|
|
214
|
+
const checks: Check[] = [
|
|
215
|
+
listed.ok
|
|
216
|
+
? {
|
|
217
|
+
id: 'definition:inventory',
|
|
218
|
+
status: 'pass',
|
|
219
|
+
summary: 'Ansible reads the inventory the definition describes.',
|
|
220
|
+
}
|
|
221
|
+
: {
|
|
222
|
+
id: 'definition:inventory',
|
|
223
|
+
status: 'fail',
|
|
224
|
+
summary: 'DEFINITION_INVENTORY_REJECTED',
|
|
225
|
+
remedy: {
|
|
226
|
+
action:
|
|
227
|
+
'Run devfleet fleet-validate --ansible on the manager after fleet-sync installed the pinned Ansible; report the failure as an issue if it persists.',
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
const failed: string[] = [];
|
|
232
|
+
for (const file of await playbooks(services.root)) {
|
|
233
|
+
const result = await services.run(
|
|
234
|
+
[
|
|
235
|
+
...playbook,
|
|
236
|
+
'-i',
|
|
237
|
+
inventory,
|
|
238
|
+
'--syntax-check',
|
|
239
|
+
join(services.root, file),
|
|
240
|
+
],
|
|
241
|
+
{ timeoutMs: 120_000, env: ansibleConfig(services.root) }
|
|
242
|
+
);
|
|
243
|
+
if (!result.ok) failed.push(file);
|
|
244
|
+
}
|
|
245
|
+
checks.push(
|
|
246
|
+
failed.length
|
|
247
|
+
? {
|
|
248
|
+
id: 'definition:playbooks',
|
|
249
|
+
status: 'fail',
|
|
250
|
+
summary: `DEFINITION_PLAYBOOK_SYNTAX: ${failed.join(', ')}`,
|
|
251
|
+
remedy: {
|
|
252
|
+
action:
|
|
253
|
+
'The installed dev-fleet package carries a playbook Ansible cannot parse; report it as an issue.',
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
: {
|
|
257
|
+
id: 'definition:playbooks',
|
|
258
|
+
status: 'pass',
|
|
259
|
+
summary: 'Every playbook passes ansible-playbook --syntax-check.',
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
return checks;
|
|
263
|
+
} finally {
|
|
264
|
+
await rm(directory, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Every top-level playbook the fleet applies, sorted. */
|
|
269
|
+
async function playbooks(root: string): Promise<string[]> {
|
|
270
|
+
const found: string[] = [];
|
|
271
|
+
for (const directory of PLAYBOOK_DIRECTORIES) {
|
|
272
|
+
const entries = await readdir(join(root, directory)).catch(() => []);
|
|
273
|
+
for (const entry of entries)
|
|
274
|
+
if (entry.endsWith('.yml')) found.push(join(directory, entry));
|
|
275
|
+
}
|
|
276
|
+
return found.sort();
|
|
277
|
+
}
|
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
|
}
|