@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/definition.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
parseWorkerPolicy,
|
|
3
|
+
policyFault,
|
|
3
4
|
toolProfiles,
|
|
4
5
|
type WorkerPolicy,
|
|
5
6
|
} from './worker-policy';
|
|
6
7
|
import type { ToolProfile } from '../bootstrap/worker-tools';
|
|
8
|
+
import {
|
|
9
|
+
featuresFault,
|
|
10
|
+
type Preset,
|
|
11
|
+
type Session,
|
|
12
|
+
} from '../bootstrap/worker-features';
|
|
7
13
|
import { readFileSync } from 'node:fs';
|
|
8
14
|
import { FleetUsageError } from './errors';
|
|
9
15
|
import { parseConfig, record } from './config';
|
|
@@ -17,21 +23,86 @@ import {
|
|
|
17
23
|
type T3Definition,
|
|
18
24
|
} from './ide/definition';
|
|
19
25
|
import { parseNotify, type NotifyConfig } from './notify';
|
|
20
|
-
import { ubuntuRelease } from './platform/ubuntu-release';
|
|
26
|
+
import { UBUNTU_RELEASES, ubuntuRelease } from './platform/ubuntu-release';
|
|
21
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';
|
|
22
34
|
|
|
23
35
|
export interface MachineDefinition {
|
|
24
36
|
id: string;
|
|
25
|
-
|
|
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;
|
|
26
42
|
os: 'linux';
|
|
27
43
|
/** Tool profiles this worker adds to the fleet-wide ones. */
|
|
28
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;
|
|
29
53
|
network?: {
|
|
30
54
|
tailscaleName: string;
|
|
31
55
|
user: string;
|
|
32
56
|
sshPort: number;
|
|
33
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;
|
|
34
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;
|
|
35
106
|
}
|
|
36
107
|
/** The singleton manager: the human's machine. It runs fleet-sync and hosts
|
|
37
108
|
* every singleton service; it is never converged over SSH. */
|
|
@@ -40,6 +111,8 @@ export interface ManagerDefinition {
|
|
|
40
111
|
os: 'macOS';
|
|
41
112
|
network: { tailscaleName: string; user: string };
|
|
42
113
|
neverSleepOnPower: true;
|
|
114
|
+
/** How often its scheduled jobs run, when not the policy's default. */
|
|
115
|
+
schedule?: { syncMinutes?: number; watchMinutes?: number };
|
|
43
116
|
}
|
|
44
117
|
export interface FleetDefinition {
|
|
45
118
|
version: 3;
|
|
@@ -54,6 +127,11 @@ export interface FleetDefinition {
|
|
|
54
127
|
manager: ManagerDefinition;
|
|
55
128
|
/** Stateless, replaceable Linux boxes; clients of the manager's services. */
|
|
56
129
|
workers: MachineDefinition[];
|
|
130
|
+
/** The worker that proves a revision new to the fleet before the others
|
|
131
|
+
* receive it, when it is reachable. Without one, the first reachable
|
|
132
|
+
* worker in the order above.
|
|
133
|
+
*/
|
|
134
|
+
canary?: string;
|
|
57
135
|
services: {
|
|
58
136
|
/** Public HTTPS origin of the CLI proxy every node uses. */
|
|
59
137
|
proxy: { endpoint: string; accounts?: AccountPolicy };
|
|
@@ -85,6 +163,12 @@ export interface FleetDefinition {
|
|
|
85
163
|
ubuntu: { release: string; architectures: string[] };
|
|
86
164
|
/** How every node signs in to GitHub; defaults when absent. */
|
|
87
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;
|
|
88
172
|
}
|
|
89
173
|
/** How provider accounts are watched and chosen (src/accounts/). */
|
|
90
174
|
export interface AccountPolicy {
|
|
@@ -124,97 +208,383 @@ export interface GitIdentity {
|
|
|
124
208
|
* credentials, proving it can push (`worker:git-push-auth`).
|
|
125
209
|
*/
|
|
126
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';
|
|
127
216
|
}
|
|
128
217
|
const ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
129
218
|
const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
219
|
+
const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
|
|
220
|
+
const MANAGER_USER = /^[a-z_][a-z0-9_.-]{0,31}$/;
|
|
221
|
+
|
|
222
|
+
/** What is wrong with a fleet definition: the first rule it breaks, where. */
|
|
223
|
+
export interface DefinitionProblem {
|
|
224
|
+
/** Stable: DEFINITION_INVALID, DEFINITION_MISSING, DEFINITION_UNKNOWN,
|
|
225
|
+
* DEFINITION_DUPLICATE or DEFINITION_UNREADABLE. */
|
|
226
|
+
code: string;
|
|
227
|
+
/** A JSONPath into fleet.json, such as `$.workers[1].network.sshPort`. */
|
|
228
|
+
path: string;
|
|
229
|
+
expected: string;
|
|
230
|
+
/** The kind of value found, never more than a short excerpt of it. */
|
|
231
|
+
actual: string;
|
|
134
232
|
}
|
|
135
|
-
|
|
136
|
-
|
|
233
|
+
|
|
234
|
+
export class DefinitionError extends FleetUsageError {
|
|
235
|
+
constructor(readonly problem: DefinitionProblem) {
|
|
236
|
+
super(
|
|
237
|
+
`Invalid fleet definition at ${problem.path}: expected ${problem.expected}, found ${problem.actual}.`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
137
240
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
)
|
|
143
|
-
|
|
241
|
+
|
|
242
|
+
/** A short description of a value, for a person to recognise it. */
|
|
243
|
+
function describe(value: unknown): string {
|
|
244
|
+
if (value === undefined) return 'nothing';
|
|
245
|
+
if (value === null) return 'null';
|
|
246
|
+
if (Array.isArray(value)) return `an array of ${value.length}`;
|
|
247
|
+
if (typeof value === 'object') return 'an object';
|
|
248
|
+
const text = JSON.stringify(value);
|
|
249
|
+
return text.length > 64 ? `${text.slice(0, 61)}...` : text;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function fail(
|
|
253
|
+
path: string,
|
|
254
|
+
expected: string,
|
|
255
|
+
actual: unknown,
|
|
256
|
+
code = actual === undefined ? 'DEFINITION_MISSING' : 'DEFINITION_INVALID'
|
|
257
|
+
): never {
|
|
258
|
+
throw new DefinitionError({
|
|
259
|
+
code,
|
|
260
|
+
path,
|
|
261
|
+
expected,
|
|
262
|
+
actual: describe(actual),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function object(value: unknown, path: string): Record<string, unknown> {
|
|
267
|
+
if (!record(value)) return fail(path, 'an object', value);
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function keys(value: Record<string, unknown>, allowed: string[], path: string) {
|
|
272
|
+
const unknown = Object.keys(value).find((key) => !allowed.includes(key));
|
|
273
|
+
if (unknown !== undefined)
|
|
274
|
+
fail(
|
|
275
|
+
`${path}.${unknown}`,
|
|
276
|
+
`one of ${allowed.filter((key) => key !== '$schema').join(', ')}`,
|
|
277
|
+
value[unknown],
|
|
278
|
+
'DEFINITION_UNKNOWN'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function text(
|
|
283
|
+
value: unknown,
|
|
284
|
+
pattern: RegExp,
|
|
285
|
+
path: string,
|
|
286
|
+
expected: string
|
|
287
|
+
): string {
|
|
288
|
+
if (typeof value !== 'string' || !pattern.test(value))
|
|
289
|
+
return fail(path, expected, value);
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function constant<T>(value: unknown, wanted: T, path: string): T {
|
|
294
|
+
if (JSON.stringify(value) !== JSON.stringify(wanted))
|
|
295
|
+
return fail(path, JSON.stringify(wanted), value);
|
|
296
|
+
return wanted;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function network(
|
|
300
|
+
value: unknown,
|
|
301
|
+
path: string
|
|
302
|
+
): NonNullable<MachineDefinition['network']> {
|
|
303
|
+
const fields = object(value, path);
|
|
304
|
+
keys(fields, ['tailscaleName', 'user', 'sshPort', 't3Port', 'hostKey'], path);
|
|
305
|
+
return {
|
|
306
|
+
tailscaleName: text(
|
|
307
|
+
fields['tailscaleName'],
|
|
308
|
+
HOST,
|
|
309
|
+
`${path}.tailscaleName`,
|
|
310
|
+
'a host name'
|
|
311
|
+
),
|
|
312
|
+
user: text(fields['user'], USER, `${path}.user`, 'a login name'),
|
|
313
|
+
sshPort: port(fields['sshPort'], `${path}.sshPort`),
|
|
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[] {
|
|
144
373
|
if (
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
)
|
|
147
381
|
)
|
|
148
|
-
return fail();
|
|
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'];
|
|
149
390
|
if (
|
|
150
|
-
|
|
151
|
-
|
|
391
|
+
hours !== undefined &&
|
|
392
|
+
(typeof hours !== 'number' || !(hours >= 1) || hours > 8760)
|
|
152
393
|
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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;
|
|
157
405
|
}
|
|
158
|
-
function
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
);
|
|
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`) }),
|
|
422
|
+
};
|
|
162
423
|
}
|
|
163
|
-
function
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
424
|
+
function port(value: unknown, path: string): number {
|
|
425
|
+
if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535)
|
|
426
|
+
return fail(path, 'a port from 1 to 65535', value);
|
|
427
|
+
return value as number;
|
|
428
|
+
}
|
|
429
|
+
function worker(value: unknown, path: string): MachineDefinition {
|
|
430
|
+
const fields = object(value, path);
|
|
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
|
+
);
|
|
448
|
+
const id = text(fields['id'], ID, `${path}.id`, 'a lowercase id');
|
|
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
|
+
);
|
|
172
459
|
// Workers are the Linux boxes; any other platform is not a worker.
|
|
173
|
-
|
|
460
|
+
constant(fields['os'], 'linux', `${path}.os`);
|
|
174
461
|
return {
|
|
175
|
-
id
|
|
176
|
-
ssh:
|
|
462
|
+
id,
|
|
463
|
+
...(ssh === undefined ? {} : { ssh }),
|
|
177
464
|
os: 'linux',
|
|
178
|
-
...(
|
|
179
|
-
|
|
465
|
+
...(fields['network']
|
|
466
|
+
? { network: network(fields['network'], `${path}.network`) }
|
|
467
|
+
: {}),
|
|
468
|
+
...described(fields, path),
|
|
469
|
+
...(fields['toolProfiles'] === undefined
|
|
470
|
+
? {}
|
|
471
|
+
: {
|
|
472
|
+
toolProfiles:
|
|
473
|
+
toolProfiles(fields['toolProfiles']) ??
|
|
474
|
+
fail(
|
|
475
|
+
`${path}.toolProfiles`,
|
|
476
|
+
'a list of known tool profiles, each once',
|
|
477
|
+
fields['toolProfiles']
|
|
478
|
+
),
|
|
479
|
+
}),
|
|
480
|
+
...(fields['exposure'] === undefined
|
|
180
481
|
? {}
|
|
181
|
-
: {
|
|
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
|
+
}),
|
|
182
500
|
};
|
|
183
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
|
+
}
|
|
184
530
|
function manager(value: unknown): ManagerDefinition {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
)
|
|
193
|
-
|
|
531
|
+
const path = '$.manager';
|
|
532
|
+
const fields = object(value, path);
|
|
533
|
+
keys(fields, ['id', 'os', 'network', 'neverSleepOnPower', 'schedule'], path);
|
|
534
|
+
const id = text(fields['id'], ID, `${path}.id`, 'a lowercase id');
|
|
535
|
+
// The manager must be the Mac: a Linux machine is a worker.
|
|
536
|
+
constant(fields['os'], 'macOS', `${path}.os`);
|
|
537
|
+
const net = object(fields['network'], `${path}.network`);
|
|
538
|
+
keys(net, ['tailscaleName', 'user'], `${path}.network`);
|
|
539
|
+
const tailscaleName = text(
|
|
540
|
+
net['tailscaleName'],
|
|
541
|
+
HOST,
|
|
542
|
+
`${path}.network.tailscaleName`,
|
|
543
|
+
'a host name'
|
|
544
|
+
);
|
|
545
|
+
const user = text(
|
|
546
|
+
net['user'],
|
|
547
|
+
MANAGER_USER,
|
|
548
|
+
`${path}.network.user`,
|
|
549
|
+
'a login name'
|
|
550
|
+
);
|
|
551
|
+
constant(fields['neverSleepOnPower'], true, `${path}.neverSleepOnPower`);
|
|
194
552
|
return {
|
|
195
|
-
id
|
|
553
|
+
id,
|
|
196
554
|
os: 'macOS',
|
|
197
|
-
network:
|
|
555
|
+
network: { tailscaleName, user },
|
|
198
556
|
neverSleepOnPower: true,
|
|
557
|
+
...(fields['schedule'] === undefined
|
|
558
|
+
? {}
|
|
559
|
+
: { schedule: schedule(fields['schedule'], `${path}.schedule`) }),
|
|
199
560
|
};
|
|
200
561
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
|
|
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;
|
|
212
582
|
}
|
|
213
583
|
export function parseDefinition(value: unknown): FleetDefinition {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
584
|
+
const fields = object(value, '$');
|
|
585
|
+
keys(
|
|
586
|
+
fields,
|
|
587
|
+
[
|
|
218
588
|
'$schema',
|
|
219
589
|
'version',
|
|
220
590
|
'source',
|
|
@@ -222,6 +592,7 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
222
592
|
'state',
|
|
223
593
|
'manager',
|
|
224
594
|
'workers',
|
|
595
|
+
'canary',
|
|
225
596
|
'services',
|
|
226
597
|
'ubuntu',
|
|
227
598
|
'worker',
|
|
@@ -230,216 +601,352 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
230
601
|
'git',
|
|
231
602
|
'notify',
|
|
232
603
|
'github',
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
604
|
+
'tailnet',
|
|
605
|
+
'parking',
|
|
606
|
+
],
|
|
607
|
+
'$'
|
|
608
|
+
);
|
|
609
|
+
constant(fields['version'], 3, '$.version');
|
|
610
|
+
const list = fields['workers'];
|
|
611
|
+
if (!Array.isArray(list) || list.length > 64)
|
|
612
|
+
return fail('$.workers', 'an array of at most 64 workers', list);
|
|
613
|
+
const lead = manager(fields['manager']);
|
|
614
|
+
const workers = list.map((entry, index) =>
|
|
615
|
+
worker(entry, `$.workers[${index}]`)
|
|
616
|
+
);
|
|
617
|
+
const seen = new Set([lead.id]);
|
|
618
|
+
workers.forEach((entry, index) => {
|
|
619
|
+
if (seen.has(entry.id))
|
|
620
|
+
fail(
|
|
621
|
+
`$.workers[${index}].id`,
|
|
622
|
+
'an id no other machine has',
|
|
623
|
+
entry.id,
|
|
624
|
+
'DEFINITION_DUPLICATE'
|
|
625
|
+
);
|
|
626
|
+
seen.add(entry.id);
|
|
627
|
+
});
|
|
242
628
|
return {
|
|
243
629
|
version: 3,
|
|
244
|
-
source: source(
|
|
245
|
-
secrets: secrets(
|
|
246
|
-
...(
|
|
630
|
+
source: source(fields['source']),
|
|
631
|
+
secrets: secrets(fields['secrets']),
|
|
632
|
+
...(fields['state'] === undefined ? {} : { state: state(fields['state']) }),
|
|
247
633
|
manager: lead,
|
|
248
634
|
workers,
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
635
|
+
...(fields['canary'] === undefined
|
|
636
|
+
? {}
|
|
637
|
+
: { canary: canary(fields['canary'], workers) }),
|
|
638
|
+
services: services(fields['services']),
|
|
639
|
+
ubuntu: baseline(fields['ubuntu']),
|
|
640
|
+
...(fields['worker'] === undefined
|
|
641
|
+
? {}
|
|
642
|
+
: { worker: policy(fields['worker']) }),
|
|
643
|
+
// Kept only when named: an earlier revision must still read the copy.
|
|
644
|
+
...(fields['ides'] === undefined
|
|
645
|
+
? {}
|
|
646
|
+
: { ides: block('$.ides', () => parseIdes(fields['ides'])) }),
|
|
647
|
+
...(fields['t3'] === undefined
|
|
648
|
+
? {}
|
|
649
|
+
: { t3: block('$.t3', () => parseT3(fields['t3'])) }),
|
|
650
|
+
...(fields['git'] === undefined ? {} : { git: gitIdentity(fields['git']) }),
|
|
651
|
+
...(fields['notify'] === undefined
|
|
652
|
+
? {}
|
|
653
|
+
: {
|
|
654
|
+
notify:
|
|
655
|
+
parseNotify(fields['notify']) ??
|
|
656
|
+
fail(
|
|
657
|
+
'$.notify',
|
|
658
|
+
'targets (webhook, ntfy) and repeatHours from 0 to 720',
|
|
659
|
+
fields['notify']
|
|
660
|
+
),
|
|
661
|
+
}),
|
|
662
|
+
...operations(fields),
|
|
252
663
|
};
|
|
253
664
|
}
|
|
254
|
-
/**
|
|
255
|
-
*
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
): Pick<
|
|
260
|
-
FleetDefinition,
|
|
261
|
-
'worker' | 'ides' | 't3' | 'git' | 'notify' | 'github'
|
|
262
|
-
> {
|
|
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'> {
|
|
263
670
|
return {
|
|
264
|
-
...(
|
|
671
|
+
...(fields['github'] === undefined
|
|
265
672
|
? {}
|
|
266
|
-
: {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
...(
|
|
270
|
-
...(value['notify'] === undefined
|
|
673
|
+
: {
|
|
674
|
+
github: block('$.github', () => parseGithubPolicy(fields['github'])),
|
|
675
|
+
}),
|
|
676
|
+
...(fields['parking'] === undefined
|
|
271
677
|
? {}
|
|
272
|
-
: {
|
|
273
|
-
...(
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
),
|
|
276
693
|
};
|
|
277
694
|
}
|
|
695
|
+
/** The canary is one of the declared workers. */
|
|
696
|
+
function canary(value: unknown, workers: MachineDefinition[]): string {
|
|
697
|
+
if (typeof value !== 'string' || !workers.some((entry) => entry.id === value))
|
|
698
|
+
return fail('$.canary', 'the id of a declared worker', value);
|
|
699
|
+
return value;
|
|
700
|
+
}
|
|
278
701
|
/** Singleton services name no host: they always run on the manager. */
|
|
279
702
|
function services(value: unknown): FleetDefinition['services'] {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
!keys(proxy, ['endpoint', 'accounts']) ||
|
|
285
|
-
!origin(proxy['endpoint'])
|
|
286
|
-
)
|
|
287
|
-
return fail();
|
|
703
|
+
const fields = object(value, '$.services');
|
|
704
|
+
keys(fields, ['proxy', 'monitoring'], '$.services');
|
|
705
|
+
const proxy = object(fields['proxy'], '$.services.proxy');
|
|
706
|
+
keys(proxy, ['endpoint', 'accounts'], '$.services.proxy');
|
|
288
707
|
return {
|
|
289
708
|
proxy: {
|
|
290
|
-
endpoint:
|
|
709
|
+
endpoint: text(
|
|
710
|
+
proxy['endpoint'],
|
|
711
|
+
ORIGIN,
|
|
712
|
+
'$.services.proxy.endpoint',
|
|
713
|
+
'an https origin without a path'
|
|
714
|
+
),
|
|
291
715
|
...(proxy['accounts'] === undefined
|
|
292
716
|
? {}
|
|
293
717
|
: { accounts: accountPolicy(proxy['accounts']) }),
|
|
294
718
|
},
|
|
295
|
-
monitoring: monitoringService(monitoring),
|
|
719
|
+
monitoring: monitoringService(fields['monitoring']),
|
|
296
720
|
};
|
|
297
721
|
}
|
|
722
|
+
/** A block parsed elsewhere: its own message says what it expects. */
|
|
723
|
+
function block<T>(path: string, parse: () => T): T {
|
|
724
|
+
try {
|
|
725
|
+
return parse();
|
|
726
|
+
} catch (error) {
|
|
727
|
+
if (error instanceof DefinitionError || !(error instanceof Error))
|
|
728
|
+
throw error;
|
|
729
|
+
return fail(path, error.message, 'something else');
|
|
730
|
+
}
|
|
731
|
+
}
|
|
298
732
|
const STRATEGIES = ['fill-first', 'round-robin', 'least-recently-limited'];
|
|
299
733
|
function accountPolicy(value: unknown): AccountPolicy {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
734
|
+
const path = '$.services.proxy.accounts';
|
|
735
|
+
const fields = object(value, path);
|
|
736
|
+
keys(fields, ['minimumUsable', 'selection'], path);
|
|
737
|
+
const { minimumUsable, selection } = fields;
|
|
303
738
|
if (
|
|
304
739
|
minimumUsable !== undefined &&
|
|
305
740
|
!(Number.isInteger(minimumUsable) && Number(minimumUsable) >= 1)
|
|
306
741
|
)
|
|
307
|
-
|
|
742
|
+
fail(`${path}.minimumUsable`, 'an integer of at least 1', minimumUsable);
|
|
308
743
|
return {
|
|
309
744
|
...(minimumUsable === undefined
|
|
310
745
|
? {}
|
|
311
746
|
: { minimumUsable: minimumUsable as number }),
|
|
312
747
|
...(selection === undefined
|
|
313
748
|
? {}
|
|
314
|
-
: { selection: selectionPolicies(selection) }),
|
|
749
|
+
: { selection: selectionPolicies(selection, `${path}.selection`) }),
|
|
315
750
|
};
|
|
316
751
|
}
|
|
317
|
-
function selectionPolicies(
|
|
318
|
-
|
|
752
|
+
function selectionPolicies(
|
|
753
|
+
value: unknown,
|
|
754
|
+
path: string
|
|
755
|
+
): Record<string, AccountSelection> {
|
|
756
|
+
const fields = object(value, path);
|
|
319
757
|
const providers: Record<string, AccountSelection> = {};
|
|
320
|
-
for (const [provider, entry] of Object.entries(
|
|
321
|
-
if (!/^[a-z][a-z0-9-]{0,31}$/.test(provider))
|
|
322
|
-
|
|
758
|
+
for (const [provider, entry] of Object.entries(fields)) {
|
|
759
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(provider))
|
|
760
|
+
fail(`${path}.${provider}`, 'a provider name', provider);
|
|
761
|
+
providers[provider] = accountSelection(entry, `${path}.${provider}`);
|
|
323
762
|
}
|
|
324
763
|
return providers;
|
|
325
764
|
}
|
|
326
|
-
function accountSelection(entry: unknown): AccountSelection {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
765
|
+
function accountSelection(entry: unknown, path: string): AccountSelection {
|
|
766
|
+
const fields = object(entry, path);
|
|
767
|
+
keys(fields, ['strategy', 'order'], path);
|
|
768
|
+
if (!STRATEGIES.includes(String(fields['strategy'])))
|
|
769
|
+
fail(
|
|
770
|
+
`${path}.strategy`,
|
|
771
|
+
`one of ${STRATEGIES.join(', ')}`,
|
|
772
|
+
fields['strategy']
|
|
773
|
+
);
|
|
774
|
+
const { order } = fields;
|
|
334
775
|
if (
|
|
335
776
|
order !== undefined &&
|
|
336
|
-
!(
|
|
777
|
+
!(
|
|
778
|
+
Array.isArray(order) &&
|
|
779
|
+
order.every((label) => typeof label === 'string' && ACCOUNT.test(label))
|
|
780
|
+
)
|
|
337
781
|
)
|
|
338
|
-
|
|
782
|
+
fail(`${path}.order`, 'a list of account email addresses', order);
|
|
339
783
|
return {
|
|
340
|
-
strategy:
|
|
784
|
+
strategy: fields['strategy'] as AccountSelection['strategy'],
|
|
341
785
|
...(order ? { order: order as string[] } : {}),
|
|
342
786
|
};
|
|
343
787
|
}
|
|
344
788
|
function monitoringService(
|
|
345
789
|
value: unknown
|
|
346
790
|
): FleetDefinition['services']['monitoring'] {
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
'viewerAccount',
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
return fail();
|
|
356
|
-
const { publicUrl, managerAccount, viewerAccount } = value;
|
|
357
|
-
if (!origin(publicUrl) || !account(managerAccount) || !account(viewerAccount))
|
|
358
|
-
return fail();
|
|
791
|
+
const path = '$.services.monitoring';
|
|
792
|
+
const fields = object(value, path);
|
|
793
|
+
keys(
|
|
794
|
+
fields,
|
|
795
|
+
['hubPort', 'publicUrl', 'managerAccount', 'viewerAccount', 'backups'],
|
|
796
|
+
path
|
|
797
|
+
);
|
|
798
|
+
constant(fields['hubPort'], 8443, `${path}.hubPort`);
|
|
359
799
|
return {
|
|
360
800
|
hubPort: 8443,
|
|
361
|
-
publicUrl
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
801
|
+
publicUrl: text(
|
|
802
|
+
fields['publicUrl'],
|
|
803
|
+
ORIGIN,
|
|
804
|
+
`${path}.publicUrl`,
|
|
805
|
+
'an https origin without a path'
|
|
806
|
+
),
|
|
807
|
+
managerAccount: text(
|
|
808
|
+
fields['managerAccount'],
|
|
809
|
+
ACCOUNT,
|
|
810
|
+
`${path}.managerAccount`,
|
|
811
|
+
'an email address'
|
|
812
|
+
),
|
|
813
|
+
viewerAccount: text(
|
|
814
|
+
fields['viewerAccount'],
|
|
815
|
+
ACCOUNT,
|
|
816
|
+
`${path}.viewerAccount`,
|
|
817
|
+
'an email address'
|
|
818
|
+
),
|
|
819
|
+
...(fields['backups'] === undefined
|
|
365
820
|
? {}
|
|
366
|
-
: { backups: hubBackups(
|
|
821
|
+
: { backups: hubBackups(fields['backups'], `${path}.backups`) }),
|
|
367
822
|
};
|
|
368
823
|
}
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
function hubBackups(value: unknown): HubBackups {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const {
|
|
375
|
-
if (
|
|
376
|
-
|
|
377
|
-
!Number.isInteger(keep) ||
|
|
378
|
-
Number(keep) < 1 ||
|
|
379
|
-
Number(keep) > 365
|
|
380
|
-
)
|
|
381
|
-
return fail();
|
|
824
|
+
const ORIGIN = /^https:\/\/[A-Za-z0-9.-]{1,253}$/;
|
|
825
|
+
const ACCOUNT = /^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/;
|
|
826
|
+
function hubBackups(value: unknown, path: string): HubBackups {
|
|
827
|
+
const fields = object(value, path);
|
|
828
|
+
keys(fields, ['schedule', 'keep', 'storage'], path);
|
|
829
|
+
const { keep } = fields;
|
|
830
|
+
if (!Number.isInteger(keep) || Number(keep) < 1 || Number(keep) > 365)
|
|
831
|
+
fail(`${path}.keep`, 'an integer from 1 to 365', keep);
|
|
382
832
|
return {
|
|
383
|
-
schedule
|
|
833
|
+
schedule: text(
|
|
834
|
+
fields['schedule'],
|
|
835
|
+
/^[0-9*/,-]+( [0-9*/,-]+){4}$/,
|
|
836
|
+
`${path}.schedule`,
|
|
837
|
+
'a cron expression of five fields'
|
|
838
|
+
),
|
|
384
839
|
keep: Number(keep),
|
|
385
|
-
storage: backupStorage(
|
|
840
|
+
storage: backupStorage(fields['storage'], `${path}.storage`),
|
|
386
841
|
};
|
|
387
842
|
}
|
|
388
|
-
function backupStorage(value: unknown): HubBackups['storage'] {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
)
|
|
393
|
-
|
|
394
|
-
const { bucket, endpoint, region, forcePathStyle } = value;
|
|
395
|
-
if (
|
|
396
|
-
!text(bucket, /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/) ||
|
|
397
|
-
!text(endpoint, /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/) ||
|
|
398
|
-
!text(region, /^[a-z0-9-]{1,32}$/) ||
|
|
399
|
-
(forcePathStyle !== undefined && typeof forcePathStyle !== 'boolean')
|
|
400
|
-
)
|
|
401
|
-
return fail();
|
|
843
|
+
function backupStorage(value: unknown, path: string): HubBackups['storage'] {
|
|
844
|
+
const fields = object(value, path);
|
|
845
|
+
keys(fields, ['bucket', 'endpoint', 'region', 'forcePathStyle'], path);
|
|
846
|
+
const { forcePathStyle } = fields;
|
|
847
|
+
if (forcePathStyle !== undefined && typeof forcePathStyle !== 'boolean')
|
|
848
|
+
fail(`${path}.forcePathStyle`, 'true or false', forcePathStyle);
|
|
402
849
|
return {
|
|
403
|
-
bucket
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
850
|
+
bucket: text(
|
|
851
|
+
fields['bucket'],
|
|
852
|
+
/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/,
|
|
853
|
+
`${path}.bucket`,
|
|
854
|
+
'an S3 bucket name'
|
|
855
|
+
),
|
|
856
|
+
endpoint: text(
|
|
857
|
+
fields['endpoint'],
|
|
858
|
+
/^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/,
|
|
859
|
+
`${path}.endpoint`,
|
|
860
|
+
'an https origin, with a port if needed'
|
|
861
|
+
),
|
|
862
|
+
region: text(
|
|
863
|
+
fields['region'],
|
|
864
|
+
/^[a-z0-9-]{1,32}$/,
|
|
865
|
+
`${path}.region`,
|
|
866
|
+
'a region name'
|
|
867
|
+
),
|
|
868
|
+
...(forcePathStyle === undefined
|
|
869
|
+
? {}
|
|
870
|
+
: { forcePathStyle: forcePathStyle as boolean }),
|
|
407
871
|
};
|
|
408
872
|
}
|
|
409
|
-
const origin = (value: unknown): value is string =>
|
|
410
|
-
typeof value === 'string' && /^https:\/\/[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
411
|
-
const account = (value: unknown): value is string =>
|
|
412
|
-
typeof value === 'string' &&
|
|
413
|
-
/^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
414
873
|
function gitIdentity(value: unknown): GitIdentity {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
return fail();
|
|
422
|
-
if (!account(email)) return fail();
|
|
423
|
-
if (
|
|
424
|
-
verifyRepository !== undefined &&
|
|
425
|
-
(typeof verifyRepository !== 'string' ||
|
|
426
|
-
!/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(
|
|
427
|
-
verifyRepository
|
|
428
|
-
))
|
|
429
|
-
)
|
|
430
|
-
return fail();
|
|
874
|
+
const path = '$.git';
|
|
875
|
+
const fields = object(value, path);
|
|
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);
|
|
431
880
|
return {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
881
|
+
// Written into a quoted Git configuration value: no quote, backslash or
|
|
882
|
+
// line break.
|
|
883
|
+
name: text(
|
|
884
|
+
fields['name'],
|
|
885
|
+
/^[^"\\\n\r]{1,100}$/,
|
|
886
|
+
`${path}.name`,
|
|
887
|
+
'a name without quotes, backslashes or line breaks'
|
|
888
|
+
),
|
|
889
|
+
email: text(fields['email'], ACCOUNT, `${path}.email`, 'an email address'),
|
|
890
|
+
...(verifyRepository === undefined
|
|
891
|
+
? {}
|
|
892
|
+
: {
|
|
893
|
+
verifyRepository: text(
|
|
894
|
+
verifyRepository,
|
|
895
|
+
/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/,
|
|
896
|
+
`${path}.verifyRepository`,
|
|
897
|
+
'an https URL of a GitHub repository'
|
|
898
|
+
),
|
|
899
|
+
}),
|
|
900
|
+
...(signing === 'ssh' ? { signing } : {}),
|
|
435
901
|
};
|
|
436
902
|
}
|
|
437
903
|
function secrets(value: unknown): SecretsConfig {
|
|
438
|
-
return
|
|
904
|
+
return (
|
|
905
|
+
parseSecretsConfig(value) ??
|
|
906
|
+
fail(
|
|
907
|
+
'$.secrets',
|
|
908
|
+
'a provider block: env, file, sops or vault with its settings',
|
|
909
|
+
value
|
|
910
|
+
)
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
const POLICY: Record<string, string> = {
|
|
914
|
+
requiredCapabilities:
|
|
915
|
+
'every capability: github, vault, t3, toolchain, workstation, worker',
|
|
916
|
+
githubLogin: 'a GitHub login',
|
|
917
|
+
vaultUser: 'a Vault user name',
|
|
918
|
+
toolchain: '"mise.lock"',
|
|
919
|
+
agentProfile: '"proxy"',
|
|
920
|
+
unattendedSudo: 'true',
|
|
921
|
+
persistentT3: 'true',
|
|
922
|
+
neverSleep: 'true',
|
|
923
|
+
toolProfiles: 'a list of known tool profiles, each once',
|
|
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',
|
|
927
|
+
};
|
|
928
|
+
function policy(value: unknown): WorkerPolicy {
|
|
929
|
+
const fields = object(value, '$.worker');
|
|
930
|
+
keys(fields, Object.keys(POLICY), '$.worker');
|
|
931
|
+
if (fields['features'] !== undefined)
|
|
932
|
+
features(fields['features'], '$.worker.features');
|
|
933
|
+
const field = policyFault(fields);
|
|
934
|
+
if (field !== null)
|
|
935
|
+
fail(`$.worker.${field}`, POLICY[field] ?? 'a valid value', fields[field]);
|
|
936
|
+
return parseWorkerPolicy(fields);
|
|
439
937
|
}
|
|
440
938
|
/** Says what is wrong with the block itself: a bucket has several settings. */
|
|
441
939
|
function state(value: unknown): BucketLocation {
|
|
442
|
-
|
|
940
|
+
let bucket;
|
|
941
|
+
try {
|
|
942
|
+
bucket = parseLocation(value);
|
|
943
|
+
} catch (error) {
|
|
944
|
+
return fail(
|
|
945
|
+
'$.state',
|
|
946
|
+
error instanceof Error ? error.message : 'a bucket or a directory',
|
|
947
|
+
value
|
|
948
|
+
);
|
|
949
|
+
}
|
|
443
950
|
if (bucket.kind === 'directory') return { directory: bucket.path };
|
|
444
951
|
const { bucket: name, endpoint, region } = bucket;
|
|
445
952
|
return {
|
|
@@ -450,36 +957,52 @@ function state(value: unknown): BucketLocation {
|
|
|
450
957
|
};
|
|
451
958
|
}
|
|
452
959
|
function source(value: unknown): FleetDefinition['source'] {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
960
|
+
const fields = object(value, '$.source');
|
|
961
|
+
keys(fields, ['repository'], '$.source');
|
|
962
|
+
return {
|
|
963
|
+
repository: text(
|
|
964
|
+
fields['repository'],
|
|
965
|
+
/^https:\/\/[A-Za-z0-9.-]+\/[A-Za-z0-9._/-]+\.git$/,
|
|
966
|
+
'$.source.repository',
|
|
967
|
+
'an https URL of a Git repository ending in .git'
|
|
968
|
+
),
|
|
969
|
+
};
|
|
463
970
|
}
|
|
464
971
|
function baseline(ubuntu: unknown): FleetDefinition['ubuntu'] {
|
|
465
|
-
|
|
466
|
-
|
|
972
|
+
const fields = object(ubuntu, '$.ubuntu');
|
|
973
|
+
keys(fields, ['release', 'architectures'], '$.ubuntu');
|
|
467
974
|
const accepted =
|
|
468
|
-
typeof
|
|
469
|
-
? ubuntuRelease(
|
|
975
|
+
typeof fields['release'] === 'string'
|
|
976
|
+
? ubuntuRelease(fields['release'])
|
|
470
977
|
: undefined;
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
978
|
+
if (!accepted)
|
|
979
|
+
return fail(
|
|
980
|
+
'$.ubuntu.release',
|
|
981
|
+
`one of ${UBUNTU_RELEASES.map((entry) => entry.release).join(', ')}`,
|
|
982
|
+
fields['release']
|
|
983
|
+
);
|
|
984
|
+
constant(
|
|
985
|
+
fields['architectures'],
|
|
986
|
+
accepted.architectures,
|
|
987
|
+
'$.ubuntu.architectures'
|
|
988
|
+
);
|
|
477
989
|
return {
|
|
478
990
|
release: accepted.release,
|
|
479
991
|
architectures: [...accepted.architectures],
|
|
480
992
|
};
|
|
481
993
|
}
|
|
482
994
|
|
|
995
|
+
/** The first rule a fleet.json breaks, or null when it is valid. */
|
|
996
|
+
export function definitionProblem(value: unknown): DefinitionProblem | null {
|
|
997
|
+
try {
|
|
998
|
+
parseDefinition(value);
|
|
999
|
+
return null;
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
if (error instanceof DefinitionError) return error.problem;
|
|
1002
|
+
throw error;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
483
1006
|
/** The configuration as every machine keeps it at
|
|
484
1007
|
* ~/.config/dev-fleet/fleet.json: without the bucket. Only the devfleet CLI
|
|
485
1008
|
* on the manager reaches the bucket, and a launcher that still runs the
|
|
@@ -492,11 +1015,18 @@ export function installedDefinition(definition: FleetDefinition): string {
|
|
|
492
1015
|
}
|
|
493
1016
|
|
|
494
1017
|
export function loadDefinition(path: string): FleetDefinition {
|
|
1018
|
+
let value: unknown;
|
|
495
1019
|
try {
|
|
496
|
-
|
|
1020
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
497
1021
|
} catch {
|
|
498
|
-
return fail(
|
|
1022
|
+
return fail(
|
|
1023
|
+
'$',
|
|
1024
|
+
'a readable JSON file',
|
|
1025
|
+
'unreadable',
|
|
1026
|
+
'DEFINITION_UNREADABLE'
|
|
1027
|
+
);
|
|
499
1028
|
}
|
|
1029
|
+
return parseDefinition(value);
|
|
500
1030
|
}
|
|
501
1031
|
/** Local v1 roster supplies paths/requirements only for explicitly enrolled workers. */
|
|
502
1032
|
export function enrolledRoster(
|
|
@@ -510,7 +1040,7 @@ export function enrolledRoster(
|
|
|
510
1040
|
throw new FleetUsageError(
|
|
511
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.'
|
|
512
1042
|
);
|
|
513
|
-
if (declared
|
|
1043
|
+
if (sshTarget(declared) !== node.ssh)
|
|
514
1044
|
throw new FleetUsageError(
|
|
515
1045
|
'Fleet SSH assignment conflicts with the local roster. Review the migration; discovery cannot overwrite explicit assignments.'
|
|
516
1046
|
);
|