@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/definition.ts
CHANGED
|
@@ -5,6 +5,11 @@ import {
|
|
|
5
5
|
type WorkerPolicy,
|
|
6
6
|
} from './worker-policy';
|
|
7
7
|
import type { ToolProfile } from '../bootstrap/worker-tools';
|
|
8
|
+
import {
|
|
9
|
+
featuresFault,
|
|
10
|
+
type Preset,
|
|
11
|
+
type Session,
|
|
12
|
+
} from '../bootstrap/worker-features';
|
|
8
13
|
import { readFileSync } from 'node:fs';
|
|
9
14
|
import { FleetUsageError } from './errors';
|
|
10
15
|
import { parseConfig, record } from './config';
|
|
@@ -20,19 +25,84 @@ import {
|
|
|
20
25
|
import { parseNotify, type NotifyConfig } from './notify';
|
|
21
26
|
import { UBUNTU_RELEASES, ubuntuRelease } from './platform/ubuntu-release';
|
|
22
27
|
import { parseGithubPolicy, type GithubPolicy } from './github/policy';
|
|
28
|
+
import {
|
|
29
|
+
EXPOSURE_EXPECTED,
|
|
30
|
+
parseExposure,
|
|
31
|
+
type ExposureDeclaration,
|
|
32
|
+
} from './exposure';
|
|
33
|
+
import { parseTailnetConfig, type TailnetConfig } from './tailnet-control';
|
|
23
34
|
|
|
24
35
|
export interface MachineDefinition {
|
|
25
36
|
id: string;
|
|
26
|
-
|
|
37
|
+
/** A Host alias in the operator's own SSH configuration: an optional
|
|
38
|
+
* override. Without one the worker is reached from `network` alone, with
|
|
39
|
+
* its host key pinned in the fleet state (src/ssh-connection.ts).
|
|
40
|
+
*/
|
|
41
|
+
ssh?: string;
|
|
27
42
|
os: 'linux';
|
|
28
43
|
/** Tool profiles this worker adds to the fleet-wide ones. */
|
|
29
44
|
toolProfiles?: ToolProfile[];
|
|
45
|
+
/** This worker's exposure, over the fleet-wide one (src/exposure.ts). */
|
|
46
|
+
exposure?: ExposureDeclaration;
|
|
47
|
+
/** This worker's features, over the fleet-wide `worker.features`. */
|
|
48
|
+
features?: FeaturesDefinition;
|
|
49
|
+
/** Who restarts this machine after a power loss when it is a virtual
|
|
50
|
+
* machine, such as "provider restarts instances"; shown with
|
|
51
|
+
* worker:power-loss-recovery, which does not apply to one. */
|
|
52
|
+
powerRecoveryNote?: string;
|
|
30
53
|
network?: {
|
|
31
54
|
tailscaleName: string;
|
|
32
55
|
user: string;
|
|
33
56
|
sshPort: number;
|
|
34
57
|
t3Port: number;
|
|
58
|
+
/** The SSH host public key, `TYPE BASE64`, given as data (a worker
|
|
59
|
+
* record): the pinned key, ahead of one pinned in the fleet state.
|
|
60
|
+
*/
|
|
61
|
+
hostKey?: string;
|
|
35
62
|
};
|
|
63
|
+
/** Free-form labels from a worker record; only displayed. */
|
|
64
|
+
labels?: Record<string, string>;
|
|
65
|
+
/** What the worker costs while it runs; only displayed and summed. */
|
|
66
|
+
billing?: WorkerBilling;
|
|
67
|
+
/** `records`: read from a file or command `workerRecords` names, so it is
|
|
68
|
+
* retired by itself when its record disappears.
|
|
69
|
+
*/
|
|
70
|
+
origin?: 'records';
|
|
71
|
+
}
|
|
72
|
+
export interface WorkerBilling {
|
|
73
|
+
/** The declared cost of one hour of running. No pricing API is asked. */
|
|
74
|
+
hourlyRate: number;
|
|
75
|
+
/** ISO 4217 code, such as USD. */
|
|
76
|
+
currency?: string;
|
|
77
|
+
note?: string;
|
|
78
|
+
}
|
|
79
|
+
/** Workers that are stopped on purpose, and when an idle one is reported. */
|
|
80
|
+
export interface ParkingPolicy {
|
|
81
|
+
/** A worker with no agent activity for this long is `worker:idle`. */
|
|
82
|
+
idleAfterHours?: number;
|
|
83
|
+
/** fleet-watch parks a worker idle longer than idleAfterHours. Off unless
|
|
84
|
+
* true.
|
|
85
|
+
*/
|
|
86
|
+
autoPark?: boolean;
|
|
87
|
+
/** Run with the worker ID as the last argument when a worker is parked
|
|
88
|
+
* (stop) or resumed (start): an infrastructure tool, never a provider API
|
|
89
|
+
* dev-fleet calls itself.
|
|
90
|
+
*/
|
|
91
|
+
stop?: string[];
|
|
92
|
+
start?: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The name SSH connects to: the alias, or the declared tailnet name. */
|
|
96
|
+
export function sshTarget(worker: MachineDefinition): string {
|
|
97
|
+
return worker.ssh ?? worker.network!.tailscaleName;
|
|
98
|
+
}
|
|
99
|
+
/** Declared worker features (bootstrap/worker-features.ts): a preset, then
|
|
100
|
+
* single features over it. Absent, a worker has containers and a desktop
|
|
101
|
+
* session, as every worker had before features existed. */
|
|
102
|
+
export interface FeaturesDefinition {
|
|
103
|
+
preset?: Preset;
|
|
104
|
+
containers?: boolean;
|
|
105
|
+
session?: Session;
|
|
36
106
|
}
|
|
37
107
|
/** The singleton manager: the human's machine. It runs fleet-sync and hosts
|
|
38
108
|
* every singleton service; it is never converged over SSH. */
|
|
@@ -41,6 +111,8 @@ export interface ManagerDefinition {
|
|
|
41
111
|
os: 'macOS';
|
|
42
112
|
network: { tailscaleName: string; user: string };
|
|
43
113
|
neverSleepOnPower: true;
|
|
114
|
+
/** How often its scheduled jobs run, when not the policy's default. */
|
|
115
|
+
schedule?: { syncMinutes?: number; watchMinutes?: number };
|
|
44
116
|
}
|
|
45
117
|
export interface FleetDefinition {
|
|
46
118
|
version: 3;
|
|
@@ -91,6 +163,12 @@ export interface FleetDefinition {
|
|
|
91
163
|
ubuntu: { release: string; architectures: string[] };
|
|
92
164
|
/** How every node signs in to GitHub; defaults when absent. */
|
|
93
165
|
github?: GithubPolicy;
|
|
166
|
+
/** The tailnet control plane new workers are enrolled with, unattended.
|
|
167
|
+
* Absent, a worker joins the tailnet through its own login.
|
|
168
|
+
*/
|
|
169
|
+
tailnet?: TailnetConfig;
|
|
170
|
+
/** Parking and idle reporting (docs/topology.md). */
|
|
171
|
+
parking?: ParkingPolicy;
|
|
94
172
|
}
|
|
95
173
|
/** How provider accounts are watched and chosen (src/accounts/). */
|
|
96
174
|
export interface AccountPolicy {
|
|
@@ -130,6 +208,11 @@ export interface GitIdentity {
|
|
|
130
208
|
* credentials, proving it can push (`worker:git-push-auth`).
|
|
131
209
|
*/
|
|
132
210
|
verifyRepository?: string;
|
|
211
|
+
/** Sign every commit and tag. `ssh`: each node signs with a key of its own,
|
|
212
|
+
* made on the node by `ansible/github/git.yml`; the private key never
|
|
213
|
+
* leaves it (`worker:git-signing`).
|
|
214
|
+
*/
|
|
215
|
+
signing?: 'ssh';
|
|
133
216
|
}
|
|
134
217
|
const ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
135
218
|
const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
|
|
@@ -218,7 +301,7 @@ function network(
|
|
|
218
301
|
path: string
|
|
219
302
|
): NonNullable<MachineDefinition['network']> {
|
|
220
303
|
const fields = object(value, path);
|
|
221
|
-
keys(fields, ['tailscaleName', 'user', 'sshPort', 't3Port'], path);
|
|
304
|
+
keys(fields, ['tailscaleName', 'user', 'sshPort', 't3Port', 'hostKey'], path);
|
|
222
305
|
return {
|
|
223
306
|
tailscaleName: text(
|
|
224
307
|
fields['tailscaleName'],
|
|
@@ -229,6 +312,113 @@ function network(
|
|
|
229
312
|
user: text(fields['user'], USER, `${path}.user`, 'a login name'),
|
|
230
313
|
sshPort: port(fields['sshPort'], `${path}.sshPort`),
|
|
231
314
|
t3Port: port(fields['t3Port'], `${path}.t3Port`),
|
|
315
|
+
...(fields['hostKey'] === undefined
|
|
316
|
+
? {}
|
|
317
|
+
: {
|
|
318
|
+
hostKey: text(
|
|
319
|
+
fields['hostKey'],
|
|
320
|
+
HOST_KEY,
|
|
321
|
+
`${path}.hostKey`,
|
|
322
|
+
'one SSH public key, "TYPE BASE64"'
|
|
323
|
+
),
|
|
324
|
+
}),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/** One OpenSSH host public key without a comment. */
|
|
328
|
+
export const HOST_KEY =
|
|
329
|
+
/^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}$/;
|
|
330
|
+
const LABEL = /^[a-z][a-z0-9_.-]{0,31}$/;
|
|
331
|
+
function labels(value: unknown, path: string): Record<string, string> {
|
|
332
|
+
const fields = object(value, path);
|
|
333
|
+
const entries = Object.entries(fields);
|
|
334
|
+
if (entries.length > 32) fail(path, 'at most 32 labels', value);
|
|
335
|
+
for (const [name, label] of entries) {
|
|
336
|
+
if (!LABEL.test(name))
|
|
337
|
+
fail(`${path}.${name}`, 'a lowercase label name', name);
|
|
338
|
+
text(label, /^[^\x00-\x1f\x7f]{0,128}$/, `${path}.${name}`, 'a short text');
|
|
339
|
+
}
|
|
340
|
+
return fields as Record<string, string>;
|
|
341
|
+
}
|
|
342
|
+
function billing(value: unknown, path: string): WorkerBilling {
|
|
343
|
+
const fields = object(value, path);
|
|
344
|
+
keys(fields, ['hourlyRate', 'currency', 'note'], path);
|
|
345
|
+
const rate = fields['hourlyRate'];
|
|
346
|
+
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0)
|
|
347
|
+
fail(`${path}.hourlyRate`, 'a number of at least 0', rate);
|
|
348
|
+
return {
|
|
349
|
+
hourlyRate: rate as number,
|
|
350
|
+
...(fields['currency'] === undefined
|
|
351
|
+
? {}
|
|
352
|
+
: {
|
|
353
|
+
currency: text(
|
|
354
|
+
fields['currency'],
|
|
355
|
+
/^[A-Z]{3}$/,
|
|
356
|
+
`${path}.currency`,
|
|
357
|
+
'a three-letter currency code'
|
|
358
|
+
),
|
|
359
|
+
}),
|
|
360
|
+
...(fields['note'] === undefined
|
|
361
|
+
? {}
|
|
362
|
+
: {
|
|
363
|
+
note: text(
|
|
364
|
+
fields['note'],
|
|
365
|
+
/^[^\x00-\x1f\x7f]{1,200}$/,
|
|
366
|
+
`${path}.note`,
|
|
367
|
+
'a short text'
|
|
368
|
+
),
|
|
369
|
+
}),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function argv(value: unknown, path: string): string[] {
|
|
373
|
+
if (
|
|
374
|
+
!Array.isArray(value) ||
|
|
375
|
+
value.length < 1 ||
|
|
376
|
+
value.length > 64 ||
|
|
377
|
+
!value.every(
|
|
378
|
+
(entry) =>
|
|
379
|
+
typeof entry === 'string' && /^[^\x00-\x1f\x7f]{1,1024}$/.test(entry)
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
return fail(path, 'a command as a list of arguments', value);
|
|
383
|
+
return value as string[];
|
|
384
|
+
}
|
|
385
|
+
function idleHours(
|
|
386
|
+
fields: Record<string, unknown>,
|
|
387
|
+
path: string
|
|
388
|
+
): number | undefined {
|
|
389
|
+
const hours = fields['idleAfterHours'];
|
|
390
|
+
if (
|
|
391
|
+
hours !== undefined &&
|
|
392
|
+
(typeof hours !== 'number' || !(hours >= 1) || hours > 8760)
|
|
393
|
+
)
|
|
394
|
+
fail(`${path}.idleAfterHours`, 'a number of hours from 1 to 8760', hours);
|
|
395
|
+
const autoPark = fields['autoPark'];
|
|
396
|
+
if (autoPark !== undefined && typeof autoPark !== 'boolean')
|
|
397
|
+
fail(`${path}.autoPark`, 'true or false', autoPark);
|
|
398
|
+
if (autoPark === true && hours === undefined)
|
|
399
|
+
fail(
|
|
400
|
+
`${path}.idleAfterHours`,
|
|
401
|
+
'a number of hours, since autoPark is on',
|
|
402
|
+
hours
|
|
403
|
+
);
|
|
404
|
+
return hours as number | undefined;
|
|
405
|
+
}
|
|
406
|
+
function parking(value: unknown): ParkingPolicy {
|
|
407
|
+
const path = '$.parking';
|
|
408
|
+
const fields = object(value, path);
|
|
409
|
+
keys(fields, ['idleAfterHours', 'autoPark', 'stop', 'start'], path);
|
|
410
|
+
const hours = idleHours(fields, path);
|
|
411
|
+
return {
|
|
412
|
+
...(hours === undefined ? {} : { idleAfterHours: hours }),
|
|
413
|
+
...(fields['autoPark'] === undefined
|
|
414
|
+
? {}
|
|
415
|
+
: { autoPark: fields['autoPark'] as boolean }),
|
|
416
|
+
...(fields['stop'] === undefined
|
|
417
|
+
? {}
|
|
418
|
+
: { stop: argv(fields['stop'], `${path}.stop`) }),
|
|
419
|
+
...(fields['start'] === undefined
|
|
420
|
+
? {}
|
|
421
|
+
: { start: argv(fields['start'], `${path}.start`) }),
|
|
232
422
|
};
|
|
233
423
|
}
|
|
234
424
|
function port(value: unknown, path: string): number {
|
|
@@ -238,18 +428,44 @@ function port(value: unknown, path: string): number {
|
|
|
238
428
|
}
|
|
239
429
|
function worker(value: unknown, path: string): MachineDefinition {
|
|
240
430
|
const fields = object(value, path);
|
|
241
|
-
keys(
|
|
431
|
+
keys(
|
|
432
|
+
fields,
|
|
433
|
+
[
|
|
434
|
+
'id',
|
|
435
|
+
'ssh',
|
|
436
|
+
'os',
|
|
437
|
+
'network',
|
|
438
|
+
'toolProfiles',
|
|
439
|
+
'exposure',
|
|
440
|
+
'features',
|
|
441
|
+
'powerRecoveryNote',
|
|
442
|
+
'labels',
|
|
443
|
+
'billing',
|
|
444
|
+
'origin',
|
|
445
|
+
],
|
|
446
|
+
path
|
|
447
|
+
);
|
|
242
448
|
const id = text(fields['id'], ID, `${path}.id`, 'a lowercase id');
|
|
243
|
-
|
|
449
|
+
// Without an alias the worker is reached from its network block.
|
|
450
|
+
const ssh =
|
|
451
|
+
fields['ssh'] === undefined && fields['network'] !== undefined
|
|
452
|
+
? undefined
|
|
453
|
+
: text(
|
|
454
|
+
fields['ssh'],
|
|
455
|
+
HOST,
|
|
456
|
+
`${path}.ssh`,
|
|
457
|
+
'an SSH host alias, or a network block to connect from'
|
|
458
|
+
);
|
|
244
459
|
// Workers are the Linux boxes; any other platform is not a worker.
|
|
245
460
|
constant(fields['os'], 'linux', `${path}.os`);
|
|
246
461
|
return {
|
|
247
462
|
id,
|
|
248
|
-
ssh,
|
|
463
|
+
...(ssh === undefined ? {} : { ssh }),
|
|
249
464
|
os: 'linux',
|
|
250
465
|
...(fields['network']
|
|
251
466
|
? { network: network(fields['network'], `${path}.network`) }
|
|
252
467
|
: {}),
|
|
468
|
+
...described(fields, path),
|
|
253
469
|
...(fields['toolProfiles'] === undefined
|
|
254
470
|
? {}
|
|
255
471
|
: {
|
|
@@ -261,12 +477,60 @@ function worker(value: unknown, path: string): MachineDefinition {
|
|
|
261
477
|
fields['toolProfiles']
|
|
262
478
|
),
|
|
263
479
|
}),
|
|
480
|
+
...(fields['exposure'] === undefined
|
|
481
|
+
? {}
|
|
482
|
+
: {
|
|
483
|
+
exposure:
|
|
484
|
+
parseExposure(fields['exposure']) ??
|
|
485
|
+
fail(`${path}.exposure`, EXPOSURE_EXPECTED, fields['exposure']),
|
|
486
|
+
}),
|
|
487
|
+
...(fields['features'] === undefined
|
|
488
|
+
? {}
|
|
489
|
+
: { features: features(fields['features'], `${path}.features`) }),
|
|
490
|
+
...(fields['powerRecoveryNote'] === undefined
|
|
491
|
+
? {}
|
|
492
|
+
: {
|
|
493
|
+
powerRecoveryNote: text(
|
|
494
|
+
fields['powerRecoveryNote'],
|
|
495
|
+
/^[^\n\r]{1,120}$/,
|
|
496
|
+
`${path}.powerRecoveryNote`,
|
|
497
|
+
'one line of at most 120 characters'
|
|
498
|
+
),
|
|
499
|
+
}),
|
|
264
500
|
};
|
|
265
501
|
}
|
|
502
|
+
/** What a worker record says about a worker beside how to reach it: its
|
|
503
|
+
* labels, what it costs and that it came from a record.
|
|
504
|
+
*/
|
|
505
|
+
function described(
|
|
506
|
+
fields: Record<string, unknown>,
|
|
507
|
+
path: string
|
|
508
|
+
): Pick<MachineDefinition, 'labels' | 'billing' | 'origin'> {
|
|
509
|
+
return {
|
|
510
|
+
...(fields['labels'] === undefined
|
|
511
|
+
? {}
|
|
512
|
+
: { labels: labels(fields['labels'], `${path}.labels`) }),
|
|
513
|
+
...(fields['billing'] === undefined
|
|
514
|
+
? {}
|
|
515
|
+
: { billing: billing(fields['billing'], `${path}.billing`) }),
|
|
516
|
+
...(fields['origin'] === undefined
|
|
517
|
+
? {}
|
|
518
|
+
: { origin: constant(fields['origin'], 'records', `${path}.origin`) }),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/** A `features` block: fleet-wide under `worker`, or one worker's own. */
|
|
522
|
+
export function features(value: unknown, path: string): FeaturesDefinition {
|
|
523
|
+
const fields = object(value, path);
|
|
524
|
+
keys(fields, ['preset', 'containers', 'session'], path);
|
|
525
|
+
const fault = featuresFault(fields);
|
|
526
|
+
if (fault !== null)
|
|
527
|
+
fail(`${path}.${fault.field}`, fault.expected, fields[fault.field]);
|
|
528
|
+
return fields as FeaturesDefinition;
|
|
529
|
+
}
|
|
266
530
|
function manager(value: unknown): ManagerDefinition {
|
|
267
531
|
const path = '$.manager';
|
|
268
532
|
const fields = object(value, path);
|
|
269
|
-
keys(fields, ['id', 'os', 'network', 'neverSleepOnPower'], path);
|
|
533
|
+
keys(fields, ['id', 'os', 'network', 'neverSleepOnPower', 'schedule'], path);
|
|
270
534
|
const id = text(fields['id'], ID, `${path}.id`, 'a lowercase id');
|
|
271
535
|
// The manager must be the Mac: a Linux machine is a worker.
|
|
272
536
|
constant(fields['os'], 'macOS', `${path}.os`);
|
|
@@ -290,8 +554,32 @@ function manager(value: unknown): ManagerDefinition {
|
|
|
290
554
|
os: 'macOS',
|
|
291
555
|
network: { tailscaleName, user },
|
|
292
556
|
neverSleepOnPower: true,
|
|
557
|
+
...(fields['schedule'] === undefined
|
|
558
|
+
? {}
|
|
559
|
+
: { schedule: schedule(fields['schedule'], `${path}.schedule`) }),
|
|
293
560
|
};
|
|
294
561
|
}
|
|
562
|
+
|
|
563
|
+
function schedule(
|
|
564
|
+
value: unknown,
|
|
565
|
+
path: string
|
|
566
|
+
): NonNullable<ManagerDefinition['schedule']> {
|
|
567
|
+
const fields = object(value, path);
|
|
568
|
+
keys(fields, ['syncMinutes', 'watchMinutes'], path);
|
|
569
|
+
const result: NonNullable<ManagerDefinition['schedule']> = {};
|
|
570
|
+
for (const key of ['syncMinutes', 'watchMinutes'] as const) {
|
|
571
|
+
const minutes = fields[key];
|
|
572
|
+
if (minutes === undefined) continue;
|
|
573
|
+
if (
|
|
574
|
+
!Number.isInteger(minutes) ||
|
|
575
|
+
Number(minutes) < 5 ||
|
|
576
|
+
Number(minutes) > 1440
|
|
577
|
+
)
|
|
578
|
+
fail(`${path}.${key}`, 'whole minutes from 5 to 1440', minutes);
|
|
579
|
+
result[key] = minutes as number;
|
|
580
|
+
}
|
|
581
|
+
return result;
|
|
582
|
+
}
|
|
295
583
|
export function parseDefinition(value: unknown): FleetDefinition {
|
|
296
584
|
const fields = object(value, '$');
|
|
297
585
|
keys(
|
|
@@ -313,6 +601,8 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
313
601
|
'git',
|
|
314
602
|
'notify',
|
|
315
603
|
'github',
|
|
604
|
+
'tailnet',
|
|
605
|
+
'parking',
|
|
316
606
|
],
|
|
317
607
|
'$'
|
|
318
608
|
);
|
|
@@ -369,11 +659,37 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
369
659
|
fields['notify']
|
|
370
660
|
),
|
|
371
661
|
}),
|
|
662
|
+
...operations(fields),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/** How every node signs in to GitHub, how workers are parked, and the
|
|
666
|
+
* tailnet control plane they are enrolled with. */
|
|
667
|
+
function operations(
|
|
668
|
+
fields: Record<string, unknown>
|
|
669
|
+
): Pick<FleetDefinition, 'github' | 'parking' | 'tailnet'> {
|
|
670
|
+
return {
|
|
372
671
|
...(fields['github'] === undefined
|
|
373
672
|
? {}
|
|
374
673
|
: {
|
|
375
674
|
github: block('$.github', () => parseGithubPolicy(fields['github'])),
|
|
376
675
|
}),
|
|
676
|
+
...(fields['parking'] === undefined
|
|
677
|
+
? {}
|
|
678
|
+
: { parking: parking(fields['parking']) }),
|
|
679
|
+
...tailnet(fields['tailnet']),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
/** The tailnet control plane new workers are enrolled with, when declared. */
|
|
683
|
+
function tailnet(value: unknown): { tailnet?: TailnetConfig } {
|
|
684
|
+
if (value === undefined) return {};
|
|
685
|
+
return {
|
|
686
|
+
tailnet:
|
|
687
|
+
parseTailnetConfig(value) ??
|
|
688
|
+
fail(
|
|
689
|
+
'$.tailnet',
|
|
690
|
+
'api (tailscale or headscale), an https url (required, with user, for headscale) and a tag:name',
|
|
691
|
+
value
|
|
692
|
+
),
|
|
377
693
|
};
|
|
378
694
|
}
|
|
379
695
|
/** The canary is one of the declared workers. */
|
|
@@ -557,8 +873,10 @@ function backupStorage(value: unknown, path: string): HubBackups['storage'] {
|
|
|
557
873
|
function gitIdentity(value: unknown): GitIdentity {
|
|
558
874
|
const path = '$.git';
|
|
559
875
|
const fields = object(value, path);
|
|
560
|
-
keys(fields, ['name', 'email', 'verifyRepository'], path);
|
|
561
|
-
const { verifyRepository } = fields;
|
|
876
|
+
keys(fields, ['name', 'email', 'verifyRepository', 'signing'], path);
|
|
877
|
+
const { verifyRepository, signing } = fields;
|
|
878
|
+
if (signing !== undefined && signing !== 'ssh')
|
|
879
|
+
fail(`${path}.signing`, '"ssh"', signing);
|
|
562
880
|
return {
|
|
563
881
|
// Written into a quoted Git configuration value: no quote, backslash or
|
|
564
882
|
// line break.
|
|
@@ -579,6 +897,7 @@ function gitIdentity(value: unknown): GitIdentity {
|
|
|
579
897
|
'an https URL of a GitHub repository'
|
|
580
898
|
),
|
|
581
899
|
}),
|
|
900
|
+
...(signing === 'ssh' ? { signing } : {}),
|
|
582
901
|
};
|
|
583
902
|
}
|
|
584
903
|
function secrets(value: unknown): SecretsConfig {
|
|
@@ -603,10 +922,14 @@ const POLICY: Record<string, string> = {
|
|
|
603
922
|
neverSleep: 'true',
|
|
604
923
|
toolProfiles: 'a list of known tool profiles, each once',
|
|
605
924
|
tools: 'a list of tool names from dev-fleet’s mise.toml, each once',
|
|
925
|
+
exposure: EXPOSURE_EXPECTED,
|
|
926
|
+
features: 'preset, containers and session',
|
|
606
927
|
};
|
|
607
928
|
function policy(value: unknown): WorkerPolicy {
|
|
608
929
|
const fields = object(value, '$.worker');
|
|
609
930
|
keys(fields, Object.keys(POLICY), '$.worker');
|
|
931
|
+
if (fields['features'] !== undefined)
|
|
932
|
+
features(fields['features'], '$.worker.features');
|
|
610
933
|
const field = policyFault(fields);
|
|
611
934
|
if (field !== null)
|
|
612
935
|
fail(`$.worker.${field}`, POLICY[field] ?? 'a valid value', fields[field]);
|
|
@@ -717,7 +1040,7 @@ export function enrolledRoster(
|
|
|
717
1040
|
throw new FleetUsageError(
|
|
718
1041
|
'Roster contains a node that is not a declared worker. Add the worker to fleet.json in Git before rollout; the manager is never a roster node.'
|
|
719
1042
|
);
|
|
720
|
-
if (declared
|
|
1043
|
+
if (sshTarget(declared) !== node.ssh)
|
|
721
1044
|
throw new FleetUsageError(
|
|
722
1045
|
'Fleet SSH assignment conflicts with the local roster. Review the migration; discovery cannot overwrite explicit assignments.'
|
|
723
1046
|
);
|
package/src/diagnostics.ts
CHANGED
|
@@ -42,7 +42,8 @@ const EXPECTED: Record<string, string> = {
|
|
|
42
42
|
*/
|
|
43
43
|
export function diagnostic(node: string, check: Check): Diagnostic {
|
|
44
44
|
const phase = check.id.split(':')[0]!;
|
|
45
|
-
|
|
45
|
+
// A check that does not apply to this machine asks nothing of anyone.
|
|
46
|
+
const failed = check.status !== 'pass' && check.status !== 'not-applicable';
|
|
46
47
|
const code = /^[A-Z][A-Z0-9_]{2,100}$/.test(check.summary)
|
|
47
48
|
? check.summary
|
|
48
49
|
: `${check.id.replaceAll(/[^a-z0-9]/g, '_').toUpperCase()}_${failed ? 'UNVERIFIED' : 'VERIFIED'}`;
|
|
@@ -59,7 +60,9 @@ export function diagnostic(node: string, check: Check): Diagnostic {
|
|
|
59
60
|
observed: check.summary,
|
|
60
61
|
scope: scope(check.id),
|
|
61
62
|
durationMs: check.phaseDurationMs ?? null,
|
|
62
|
-
retryable:
|
|
63
|
+
retryable:
|
|
64
|
+
failed &&
|
|
65
|
+
/OFFLINE|UNREACHABLE|METRICS_STALE|TIMEOUT|FIRST_BOOT_PENDING/.test(code),
|
|
63
66
|
actionRequired: failed,
|
|
64
67
|
remedy: {
|
|
65
68
|
owner: owner(phase),
|
package/src/exposure.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { FleetDefinition } from './definition';
|
|
2
|
+
import { record } from './config';
|
|
3
|
+
|
|
4
|
+
/** What a worker opens beyond loopback and the tailnet (docs/ubuntu-sync.md,
|
|
5
|
+
* Exposure). Declared fleet-wide as `worker.exposure` and per worker as
|
|
6
|
+
* `exposure`; a field a worker names replaces the fleet-wide one. Applied by
|
|
7
|
+
* ansible/ubuntu/tasks/firewall.yml and ssh.yml, measured as worker:exposure
|
|
8
|
+
* and worker:public-reachability.
|
|
9
|
+
*/
|
|
10
|
+
export interface ExposurePolicy {
|
|
11
|
+
/** Break-glass: the SSH port stays open on every interface. */
|
|
12
|
+
publicSsh: boolean;
|
|
13
|
+
/** Opened on every interface; a listener on one is allowed. */
|
|
14
|
+
publicPorts: PublicPort[];
|
|
15
|
+
/** A listener outside the allow-list that no fleet service owns, such as
|
|
16
|
+
* an agent's development server: reported as a warning or a failure. The
|
|
17
|
+
* firewall closes it either way.
|
|
18
|
+
*/
|
|
19
|
+
otherListeners: 'warn' | 'fail';
|
|
20
|
+
}
|
|
21
|
+
export interface PublicPort {
|
|
22
|
+
protocol: 'tcp' | 'udp';
|
|
23
|
+
port: number;
|
|
24
|
+
}
|
|
25
|
+
export type ExposureDeclaration = Partial<ExposurePolicy>;
|
|
26
|
+
|
|
27
|
+
/** Nothing public: inbound only over loopback and the tailnet. */
|
|
28
|
+
export const DEFAULT_EXPOSURE: ExposurePolicy = {
|
|
29
|
+
publicSsh: false,
|
|
30
|
+
publicPorts: [],
|
|
31
|
+
otherListeners: 'warn',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const EXPOSURE_EXPECTED =
|
|
35
|
+
'publicSsh (true or false), publicPorts (a list of {protocol: tcp or udp, port}, each once) and otherListeners ("warn" or "fail")';
|
|
36
|
+
|
|
37
|
+
function publicPort(value: unknown): PublicPort | null {
|
|
38
|
+
if (!record(value)) return null;
|
|
39
|
+
const { protocol, port } = value;
|
|
40
|
+
if (Object.keys(value).some((key) => key !== 'protocol' && key !== 'port'))
|
|
41
|
+
return null;
|
|
42
|
+
if (protocol !== 'tcp' && protocol !== 'udp') return null;
|
|
43
|
+
if (!Number.isInteger(port) || Number(port) < 1 || Number(port) > 65535)
|
|
44
|
+
return null;
|
|
45
|
+
return { protocol, port: port as number };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A list of ports, each once; null when it is not one. */
|
|
49
|
+
function publicPorts(value: unknown): PublicPort[] | null {
|
|
50
|
+
if (!Array.isArray(value) || value.length > 32) return null;
|
|
51
|
+
const parsed = value.map(publicPort);
|
|
52
|
+
if (parsed.some((entry) => entry === null)) return null;
|
|
53
|
+
const ports = parsed as PublicPort[];
|
|
54
|
+
const keys = ports.map((entry) => `${entry.protocol}/${entry.port}`);
|
|
55
|
+
return new Set(keys).size === keys.length ? ports : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const FIELDS: Record<string, (value: unknown) => boolean> = {
|
|
59
|
+
publicSsh: (value) => typeof value === 'boolean',
|
|
60
|
+
publicPorts: (value) => publicPorts(value) !== null,
|
|
61
|
+
otherListeners: (value) => value === 'warn' || value === 'fail',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** An exposure declaration, or null when it is not one. */
|
|
65
|
+
export function parseExposure(value: unknown): ExposureDeclaration | null {
|
|
66
|
+
if (!record(value)) return null;
|
|
67
|
+
const valid = Object.entries(value).every(
|
|
68
|
+
([key, field]) => Object.hasOwn(FIELDS, key) && FIELDS[key]!(field)
|
|
69
|
+
);
|
|
70
|
+
return valid ? ({ ...value } as ExposureDeclaration) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The policy one worker runs under: the default, then the fleet-wide
|
|
74
|
+
* declaration, then the worker's own.
|
|
75
|
+
*/
|
|
76
|
+
export function effectiveExposure(
|
|
77
|
+
fleetWide: ExposureDeclaration | undefined,
|
|
78
|
+
own: ExposureDeclaration | undefined
|
|
79
|
+
): ExposurePolicy {
|
|
80
|
+
return { ...DEFAULT_EXPOSURE, ...fleetWide, ...own };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What a worker opens and listens on, from the fleet configuration. */
|
|
84
|
+
export interface WorkerExposure {
|
|
85
|
+
policy: ExposurePolicy;
|
|
86
|
+
/** The port sshd listens on (network.sshPort; 22 when undeclared). */
|
|
87
|
+
sshPort: number;
|
|
88
|
+
/** The port T3 serves on loopback (network.t3Port; 3773). */
|
|
89
|
+
t3Port: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function workerExposure(
|
|
93
|
+
definition: Pick<FleetDefinition, 'worker' | 'workers'>,
|
|
94
|
+
node: string
|
|
95
|
+
): WorkerExposure {
|
|
96
|
+
const machine = definition.workers.find((entry) => entry.id === node);
|
|
97
|
+
return {
|
|
98
|
+
policy: effectiveExposure(definition.worker?.exposure, machine?.exposure),
|
|
99
|
+
sshPort: machine?.network?.sshPort ?? 22,
|
|
100
|
+
t3Port: machine?.network?.t3Port ?? 3773,
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/fleet-config.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { isOperator, stateDefinition } from './state/operator';
|
|
25
25
|
import type { BucketLocation } from './state/bucket';
|
|
26
26
|
import { definitionPath, stateRoot } from './state/paths';
|
|
27
|
+
import { expandRecords } from './worker-records';
|
|
27
28
|
|
|
28
29
|
/** Where every machine keeps the fleet configuration fleet-sync approved.
|
|
29
30
|
* Launchers and scheduled jobs get no environment and read it from here.
|
|
@@ -96,7 +97,8 @@ function read(path: string): FleetDefinition {
|
|
|
96
97
|
`The fleet configuration at ${path} is not readable JSON.`
|
|
97
98
|
);
|
|
98
99
|
}
|
|
99
|
-
|
|
100
|
+
// A project's fleet.json may name generated worker records beside it.
|
|
101
|
+
return parseDefinition(expandRecords(value, dirname(path)));
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
function load(): FleetDefinition {
|