@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/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 {
|
package/src/git-identity.ts
CHANGED
|
@@ -25,6 +25,7 @@ export async function convergeGitIdentity(
|
|
|
25
25
|
git_name: git.name,
|
|
26
26
|
git_email: git.email,
|
|
27
27
|
git_home: home,
|
|
28
|
+
git_signing: git.signing ?? '',
|
|
28
29
|
}),
|
|
29
30
|
}
|
|
30
31
|
);
|
|
@@ -33,7 +34,9 @@ export async function convergeGitIdentity(
|
|
|
33
34
|
id: 'git:configuration',
|
|
34
35
|
status: result.ok ? 'pass' : 'fail',
|
|
35
36
|
summary: result.ok
|
|
36
|
-
?
|
|
37
|
+
? git.signing
|
|
38
|
+
? "The declared Git identity, the GitHub credential helper and commit signing with this node's own SSH key are written in ~/.config/git/config."
|
|
39
|
+
: 'The declared Git identity and the GitHub credential helper are written in ~/.config/git/config.'
|
|
37
40
|
: 'GIT_CONFIGURATION_INCOMPLETE',
|
|
38
41
|
...(result.ok
|
|
39
42
|
? {}
|
|
@@ -152,9 +155,79 @@ export async function gitPushAuthCheck(
|
|
|
152
155
|
};
|
|
153
156
|
}
|
|
154
157
|
|
|
158
|
+
/** The node's own commit signing key, made on the node by
|
|
159
|
+
* `ansible/github/git.yml`: an interface, like every path written to a
|
|
160
|
+
* machine.
|
|
161
|
+
*/
|
|
162
|
+
export function signingKey(home: string = homedir()): string {
|
|
163
|
+
return resolve(home, '.ssh/dev-fleet-git-signing');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What the signing probe signs: a fixed text, so nothing of the node's is. */
|
|
167
|
+
const SIGNING_PROBE = 'dev-fleet signing probe\n';
|
|
168
|
+
|
|
169
|
+
/** With `git.signing` declared, every commit and tag is signed with the
|
|
170
|
+
* node's own key: Git is configured to, and the key really signs. Without it
|
|
171
|
+
* there is no check.
|
|
172
|
+
*/
|
|
173
|
+
export async function gitSigningCheck(
|
|
174
|
+
git: GitIdentity | undefined,
|
|
175
|
+
run: Runner,
|
|
176
|
+
home: string = homedir()
|
|
177
|
+
): Promise<Check | null> {
|
|
178
|
+
if (git?.signing !== 'ssh') return null;
|
|
179
|
+
const id = 'worker:git-signing';
|
|
180
|
+
const key = signingKey(home);
|
|
181
|
+
const format = await effective(run, 'gpg.format');
|
|
182
|
+
const signer = await effective(run, 'user.signingkey');
|
|
183
|
+
const always = await effective(run, 'commit.gpgsign');
|
|
184
|
+
if (format !== 'ssh' || signer !== `${key}.pub` || always !== 'true')
|
|
185
|
+
return {
|
|
186
|
+
id,
|
|
187
|
+
status: 'fail',
|
|
188
|
+
summary:
|
|
189
|
+
!format && !signer && !always
|
|
190
|
+
? 'GIT_SIGNING_MISSING'
|
|
191
|
+
: 'GIT_SIGNING_OVERRIDDEN',
|
|
192
|
+
remedy: {
|
|
193
|
+
action:
|
|
194
|
+
'Rerun fleet-sync: it makes the node its own signing key and writes the signing settings in ~/.config/git/config. A ~/.gitconfig that sets gpg.format, user.signingkey or commit.gpgsign wins over it; remove those lines.',
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
const signed = await run(
|
|
198
|
+
['ssh-keygen', '-Y', 'sign', '-n', 'git', '-f', key],
|
|
199
|
+
{
|
|
200
|
+
input: SIGNING_PROBE,
|
|
201
|
+
timeoutMs: 10_000,
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
if (!signed.ok || !signed.stdout.includes('-----BEGIN SSH SIGNATURE-----'))
|
|
205
|
+
return {
|
|
206
|
+
id,
|
|
207
|
+
status: 'fail',
|
|
208
|
+
summary: 'GIT_SIGNING_FAILED',
|
|
209
|
+
remedy: {
|
|
210
|
+
action:
|
|
211
|
+
"The node's signing key (~/.ssh/dev-fleet-git-signing) is missing or cannot sign without a prompt. Remove both halves of it and rerun fleet-sync, which makes a new one; then add the new public key to the GitHub account as a signing key.",
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
return {
|
|
215
|
+
id,
|
|
216
|
+
status: 'pass',
|
|
217
|
+
summary:
|
|
218
|
+
'Commits and tags on this node are signed with its own SSH key. GitHub shows them as verified once ~/.ssh/dev-fleet-git-signing.pub is a signing key of the account.',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
155
222
|
export async function gitChecks(
|
|
156
223
|
git: GitIdentity | undefined,
|
|
157
|
-
run: Runner
|
|
224
|
+
run: Runner,
|
|
225
|
+
home: string = homedir()
|
|
158
226
|
): Promise<Check[]> {
|
|
159
|
-
|
|
227
|
+
const signing = await gitSigningCheck(git, run, home);
|
|
228
|
+
return [
|
|
229
|
+
await gitIdentityCheck(git, run),
|
|
230
|
+
await gitPushAuthCheck(git, run),
|
|
231
|
+
...(signing ? [signing] : []),
|
|
232
|
+
];
|
|
160
233
|
}
|
package/src/ide/ide.ts
CHANGED
|
@@ -40,4 +40,9 @@ export interface WorkerContext {
|
|
|
40
40
|
* time it is seen through a verified link.
|
|
41
41
|
*/
|
|
42
42
|
remember?: (node: string, identity: string) => Promise<void>;
|
|
43
|
+
/** Pairs the manager's IDE with the worker's when the manager cannot
|
|
44
|
+
* reach it and a pairing was asked for (a replaced worker): the check
|
|
45
|
+
* that says how it went, or nothing when no pairing was asked for.
|
|
46
|
+
*/
|
|
47
|
+
pair?: (node: FleetNode) => Promise<Check | undefined>;
|
|
43
48
|
}
|
package/src/ide/t3.ts
CHANGED
|
@@ -78,7 +78,7 @@ export function t3Ide(inputs: T3Inputs = {}): Ide {
|
|
|
78
78
|
await managerVersion(home(), context.workspace),
|
|
79
79
|
await updatesCheck(
|
|
80
80
|
inputs.policyPath ?? MACOS_POLICY,
|
|
81
|
-
`cd ${context.workspace} && ${ansibleCommand().join(' ')} -K ansible/macos/manager.yml`
|
|
81
|
+
`cd ${context.workspace} && ${ansibleCommand().join(' ')} -e user=$(id -un) -K ansible/macos/manager-privilege.yml`
|
|
82
82
|
),
|
|
83
83
|
];
|
|
84
84
|
},
|
|
@@ -89,9 +89,19 @@ export function t3Ide(inputs: T3Inputs = {}): Ide {
|
|
|
89
89
|
const restarted = context.earlier.some(
|
|
90
90
|
(check) => check.summary === T3_RESTARTED_SUMMARY
|
|
91
91
|
);
|
|
92
|
-
|
|
92
|
+
let linked = await verifyT3Linked(node, context.run, {
|
|
93
93
|
reconnectMs: restarted ? 180_000 : 0,
|
|
94
94
|
});
|
|
95
|
+
// A replaced worker: the manager pairs with its new environment.
|
|
96
|
+
const paired =
|
|
97
|
+
linked.status === 'fail' && context.pair
|
|
98
|
+
? await context.pair(node)
|
|
99
|
+
: undefined;
|
|
100
|
+
if (paired) {
|
|
101
|
+
checks.push(paired);
|
|
102
|
+
if (paired.status === 'pass')
|
|
103
|
+
linked = await verifyT3Linked(node, context.run);
|
|
104
|
+
}
|
|
95
105
|
checks.push(linked);
|
|
96
106
|
if (
|
|
97
107
|
linked.status !== 'fail' &&
|
package/src/input-hashes.ts
CHANGED
|
@@ -22,10 +22,16 @@ export async function fileHash(path: string): Promise<string | null> {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** A repository without a packages directory (dev-fleet itself) has an
|
|
26
|
+
* empty set of workspace manifests, which is known, not missing.
|
|
27
|
+
*/
|
|
25
28
|
export async function manifestHash(root: string): Promise<string | null> {
|
|
26
29
|
try {
|
|
27
30
|
const entries = await readdir(join(root, 'packages'), {
|
|
28
31
|
withFileTypes: true,
|
|
32
|
+
}).catch((error: NodeJS.ErrnoException) => {
|
|
33
|
+
if (error.code === 'ENOENT') return [];
|
|
34
|
+
throw error;
|
|
29
35
|
});
|
|
30
36
|
const hashes: string[] = [];
|
|
31
37
|
for (const entry of entries
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Runner } from '../types';
|
|
2
|
+
|
|
3
|
+
/** What kind of machine a worker is. A virtual machine has no lid, no
|
|
4
|
+
* battery and no firmware its guest can read, and restarting it after a
|
|
5
|
+
* power loss is its provider's job: checks of those are not applicable to
|
|
6
|
+
* it. Containers and WSL2 are not workers at all (docs/support-matrix.md).
|
|
7
|
+
*/
|
|
8
|
+
export type MachineClass = 'physical' | 'virtual' | 'unsupported';
|
|
9
|
+
|
|
10
|
+
/** What systemd-detect-virt prints for a container or WSL rather than a
|
|
11
|
+
* hypervisor. */
|
|
12
|
+
const CONTAINERS = [
|
|
13
|
+
'container-other',
|
|
14
|
+
'docker',
|
|
15
|
+
'lxc',
|
|
16
|
+
'lxc-libvirt',
|
|
17
|
+
'openvz',
|
|
18
|
+
'podman',
|
|
19
|
+
'pouch',
|
|
20
|
+
'proot',
|
|
21
|
+
'rkt',
|
|
22
|
+
'systemd-nspawn',
|
|
23
|
+
'wsl',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** The class of a machine from the probe's environment word (`machine`,
|
|
27
|
+
* `container` or `wsl`) and what systemd-detect-virt printed: `none` is bare
|
|
28
|
+
* metal, a hypervisor (`kvm`, `qemu`, `amazon`, `microsoft`, ...) a virtual
|
|
29
|
+
* machine. When it cannot tell (`unknown`, or nothing), the machine is
|
|
30
|
+
* taken as physical, so no check that applies to one is left out.
|
|
31
|
+
*/
|
|
32
|
+
export function machineClass(
|
|
33
|
+
environment: string,
|
|
34
|
+
virtualization: string
|
|
35
|
+
): MachineClass {
|
|
36
|
+
if (environment !== 'machine' || CONTAINERS.includes(virtualization))
|
|
37
|
+
return 'unsupported';
|
|
38
|
+
return ['', 'none', 'unknown'].includes(virtualization)
|
|
39
|
+
? 'physical'
|
|
40
|
+
: 'virtual';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The class of the machine this runs on, and the hypervisor, measured by
|
|
44
|
+
* systemd-detect-virt. The worker's own checks use it; the manager's probe
|
|
45
|
+
* records it for each worker before anything is installed. */
|
|
46
|
+
export async function detectMachineClass(
|
|
47
|
+
run: Runner
|
|
48
|
+
): Promise<{ machine: MachineClass; virtualization: string }> {
|
|
49
|
+
const detected = await run(['systemd-detect-virt']);
|
|
50
|
+
const word = detected.stdout.trim();
|
|
51
|
+
const virtualization = /^[a-z0-9-]{1,32}$/.test(word) ? word : 'unknown';
|
|
52
|
+
return {
|
|
53
|
+
machine: machineClass(
|
|
54
|
+
CONTAINERS.includes(virtualization) ? 'container' : 'machine',
|
|
55
|
+
virtualization
|
|
56
|
+
),
|
|
57
|
+
virtualization,
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/machine/platform.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import platforms from '../../ansible/platforms.json';
|
|
2
|
-
import {
|
|
2
|
+
import { UBUNTU_WORKER, workerPlatform } from '../platform/ubuntu-worker';
|
|
3
|
+
import { belowMinimum, MINIMUM_WORKER } from '../worker-sizing';
|
|
4
|
+
import { machineClass } from './machine-class';
|
|
3
5
|
|
|
4
|
-
/** One POSIX probe for every worker,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
6
|
+
/** One POSIX probe for every worker, nine lines: family, release, CPU, the
|
|
7
|
+
* environment it runs in (`wsl`, `container` or `machine`, a physical or
|
|
8
|
+
* virtual machine of its own), the hypervisor systemd-detect-virt names
|
|
9
|
+
* (`none` on bare metal, `unknown` when it cannot tell), the CPU count, all
|
|
10
|
+
* memory in MiB, the size of the root filesystem in MiB, and its clock in
|
|
11
|
+
* seconds since the epoch, which the manager compares with its own.
|
|
12
|
+
* Read-only, and it needs nothing installed. `root` moves the files it
|
|
13
|
+
* reads, for a stand-in; workers use `/`.
|
|
8
14
|
*/
|
|
9
15
|
export function platformProbe(root = ''): string {
|
|
10
16
|
return `sh -c '${[
|
|
@@ -13,31 +19,54 @@ export function platformProbe(root = ''): string {
|
|
|
13
19
|
`if grep -qiE "microsoft|wsl" ${root}/proc/sys/kernel/osrelease 2>/dev/null; then echo wsl`,
|
|
14
20
|
`elif [ -e ${root}/.dockerenv ] || [ -e ${root}/run/.containerenv ] || systemd-detect-virt --container --quiet 2>/dev/null; then echo container`,
|
|
15
21
|
'else echo machine; fi',
|
|
22
|
+
'v=$(systemd-detect-virt --vm 2>/dev/null)',
|
|
23
|
+
'echo "${v:-unknown}"',
|
|
24
|
+
'nproc 2>/dev/null || echo',
|
|
25
|
+
// Each size prints its own line, empty when unread, so the clock after
|
|
26
|
+
// them stays on the ninth.
|
|
27
|
+
`m=$(awk "/^MemTotal:/{print int(\\$2/1024)}" ${root}/proc/meminfo 2>/dev/null)`,
|
|
28
|
+
'echo "$m"',
|
|
29
|
+
`d=$(df -Pk ${root}/ 2>/dev/null | awk "NR==2{print int(\\$2/1024)}")`,
|
|
30
|
+
'echo "$d"',
|
|
31
|
+
'date -u +%s.%N',
|
|
16
32
|
].join('; ')}'`;
|
|
17
33
|
}
|
|
18
34
|
export const PLATFORM_PROBE = platformProbe();
|
|
19
35
|
|
|
20
36
|
export interface PlatformBaseline {
|
|
21
|
-
id: 'ubuntu:release' | 'platform:environment';
|
|
37
|
+
id: 'ubuntu:release' | 'platform:environment' | 'platform:worker-size';
|
|
22
38
|
supported: boolean;
|
|
23
39
|
summary: string;
|
|
24
40
|
}
|
|
25
41
|
|
|
26
|
-
/** A worker is an accepted
|
|
42
|
+
/** A worker is an accepted release of a worker platform on one of that
|
|
43
|
+
* release's CPUs. The check keeps its ID and its refusal: Ubuntu is the only
|
|
44
|
+
* worker platform, and anything else is refused as not that baseline. */
|
|
27
45
|
export function evaluatePlatform(probe: string): PlatformBaseline {
|
|
28
46
|
const [family, version, cpu] = probe.split('\n').map((line) => line.trim());
|
|
29
|
-
const
|
|
47
|
+
const platform = workerPlatform(family);
|
|
48
|
+
const release = platform?.release(version);
|
|
30
49
|
const supported =
|
|
31
|
-
|
|
50
|
+
platform !== undefined &&
|
|
51
|
+
release !== undefined &&
|
|
52
|
+
platform.kernelArchitectures(release).includes(cpu ?? '');
|
|
32
53
|
return {
|
|
33
54
|
id: 'ubuntu:release',
|
|
34
55
|
supported,
|
|
35
56
|
summary: supported
|
|
36
|
-
?
|
|
37
|
-
:
|
|
57
|
+
? `${platform.label} ${release.release} and supported CPU architecture observed.`
|
|
58
|
+
: (platform ?? UBUNTU_WORKER).mismatch,
|
|
38
59
|
};
|
|
39
60
|
}
|
|
40
61
|
|
|
62
|
+
/** The clock the probe read, in seconds since the epoch, or undefined. */
|
|
63
|
+
export function probedClock(probe: string): number | undefined {
|
|
64
|
+
const line = probe.split('\n')[8]?.trim() ?? '';
|
|
65
|
+
// GNU date prints nanoseconds; another date prints a literal N.
|
|
66
|
+
const match = /^(\d{9,})(\.\d+)?/.exec(line);
|
|
67
|
+
return match ? Number(match[1]! + (match[2] ?? '')) : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
/** Environments the support matrix (ansible/platforms.json) refuses, by the
|
|
42
71
|
* word the probe prints for them. */
|
|
43
72
|
const REFUSED = new Map(
|
|
@@ -62,3 +91,53 @@ export function evaluateEnvironment(probe: string): PlatformBaseline {
|
|
|
62
91
|
: `WORKER_ENVIRONMENT_UNSUPPORTED: ${refused ?? 'unknown environment'}`,
|
|
63
92
|
};
|
|
64
93
|
}
|
|
94
|
+
|
|
95
|
+
const line = (probe: string, index: number): string =>
|
|
96
|
+
probe.split('\n')[index]?.trim() ?? '';
|
|
97
|
+
|
|
98
|
+
/** The class of machine the probe found: physical, virtual or unsupported
|
|
99
|
+
* (a container or WSL2), recorded for every worker. A worker that cannot
|
|
100
|
+
* say which hypervisor it runs on is taken as physical, so nothing that
|
|
101
|
+
* applies to a physical machine is left out. */
|
|
102
|
+
export function evaluateMachineClass(probe: string): {
|
|
103
|
+
id: 'platform:machine-class';
|
|
104
|
+
summary: string;
|
|
105
|
+
} {
|
|
106
|
+
const environment = line(probe, 3);
|
|
107
|
+
const virtualization = line(probe, 4) || 'unknown';
|
|
108
|
+
const found = machineClass(environment, virtualization);
|
|
109
|
+
return {
|
|
110
|
+
id: 'platform:machine-class',
|
|
111
|
+
summary:
|
|
112
|
+
found === 'virtual'
|
|
113
|
+
? `Virtual machine (${virtualization}): checks of firmware power recovery and of a lid do not apply.`
|
|
114
|
+
: found === 'physical'
|
|
115
|
+
? `Physical machine (systemd-detect-virt reported ${virtualization}).`
|
|
116
|
+
: `Unsupported: ${environment || 'unknown environment'}.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const BELOW_MINIMUM = 'WORKER_SIZE_BELOW_MINIMUM';
|
|
121
|
+
|
|
122
|
+
/** The machine against the stated minimum worker size (`sizing` in the
|
|
123
|
+
* Ubuntu policy), before anything is installed; null when the probe did not
|
|
124
|
+
* report a size. */
|
|
125
|
+
export function evaluateSize(probe: string): PlatformBaseline | null {
|
|
126
|
+
const [cpus, memoryMiB, diskMiB] = [5, 6, 7].map((index) =>
|
|
127
|
+
Number(line(probe, index) || NaN)
|
|
128
|
+
);
|
|
129
|
+
if (![cpus, memoryMiB, diskMiB].every((value) => Number.isInteger(value)))
|
|
130
|
+
return null;
|
|
131
|
+
const lacks = belowMinimum({
|
|
132
|
+
cpus: cpus!,
|
|
133
|
+
memoryMiB: memoryMiB!,
|
|
134
|
+
diskMiB: diskMiB!,
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
id: 'platform:worker-size',
|
|
138
|
+
supported: lacks.length === 0,
|
|
139
|
+
summary: lacks.length
|
|
140
|
+
? `${BELOW_MINIMUM}: ${lacks.join(', ')}`
|
|
141
|
+
: `${cpus} CPUs, ${memoryMiB} MiB of memory and a ${diskMiB} MiB root filesystem: at least the minimum worker (${MINIMUM_WORKER.cpus} CPUs, ${MINIMUM_WORKER.memoryMiB} MiB, ${MINIMUM_WORKER.diskMiB} MiB).`,
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/machine/state.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface Enrollment {
|
|
|
20
20
|
approvedRevision: string;
|
|
21
21
|
appliedRevision?: string;
|
|
22
22
|
previousRevision?: string;
|
|
23
|
+
/** The revision each worker last converged to, by fleet id. A worker
|
|
24
|
+
* keeps its progress when the fleet as a whole is not ready.
|
|
25
|
+
*/
|
|
26
|
+
workers?: Record<string, string>;
|
|
27
|
+
/** Tombstones: when each retired worker left the fleet, by fleet id. */
|
|
28
|
+
retired?: Record<string, string>;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export const isRevision = (value: unknown): value is string =>
|
|
@@ -80,20 +86,39 @@ async function lockAbandoned(lock: string): Promise<boolean> {
|
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
88
|
|
|
83
|
-
/** Removes an abandoned lock.
|
|
84
|
-
*
|
|
85
|
-
|
|
89
|
+
/** Removes an abandoned lock. Judging a lock abandoned and renaming it aside
|
|
90
|
+
* are two steps: between them another reclaimer can have replaced it with a
|
|
91
|
+
* live lock of its own. So the lock is judged again where it was put aside,
|
|
92
|
+
* where nobody else can change it, and put back when it turns out to be held.
|
|
93
|
+
* `judged` runs between the two steps; only the features pause there. */
|
|
94
|
+
async function reclaimAbandonedLock(
|
|
95
|
+
lock: string,
|
|
96
|
+
judged: () => Promise<void>
|
|
97
|
+
): Promise<boolean> {
|
|
86
98
|
if (!(await lockAbandoned(lock))) return false;
|
|
99
|
+
await judged();
|
|
87
100
|
const aside = `${lock}.abandoned-${randomUUID()}`;
|
|
88
101
|
try {
|
|
89
102
|
await rename(lock, aside);
|
|
90
103
|
} catch {
|
|
91
104
|
return true; // Someone else already recovered it; retry.
|
|
92
105
|
}
|
|
106
|
+
if (!(await lockAbandoned(aside))) {
|
|
107
|
+
await rename(aside, lock).catch(() => undefined);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
93
110
|
await rm(aside, { recursive: true, force: true });
|
|
94
111
|
return true;
|
|
95
112
|
}
|
|
96
113
|
|
|
114
|
+
/** Removes the lock only while it is still the one this run wrote. */
|
|
115
|
+
async function releaseLock(lock: string, owner: string): Promise<void> {
|
|
116
|
+
const current = await readFile(join(lock, 'owner.json'), 'utf8').catch(
|
|
117
|
+
() => null
|
|
118
|
+
);
|
|
119
|
+
if (current === owner) await rm(lock, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
97
122
|
/** Bundles are written next to the lock and removed in a `finally`, which a
|
|
98
123
|
* killed run never reaches. Holding the lock means none is in use. */
|
|
99
124
|
async function sweepTransferBundles(root: string): Promise<void> {
|
|
@@ -107,7 +132,8 @@ async function sweepTransferBundles(root: string): Promise<void> {
|
|
|
107
132
|
* cleanup. Never steals a lock from a still-running installer. */
|
|
108
133
|
export async function withMachineLock<T>(
|
|
109
134
|
root: string,
|
|
110
|
-
operation: () => Promise<T
|
|
135
|
+
operation: () => Promise<T>,
|
|
136
|
+
judged: () => Promise<void> = async () => {}
|
|
111
137
|
): Promise<T> {
|
|
112
138
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
113
139
|
const lock = join(root, 'sync.lock');
|
|
@@ -120,26 +146,28 @@ export async function withMachineLock<T>(
|
|
|
120
146
|
throw new FleetUsageError(
|
|
121
147
|
'Cannot create manager lock; inspect private state-directory permissions and free disk space.'
|
|
122
148
|
);
|
|
123
|
-
if (attempt < 3 && (await reclaimAbandonedLock(lock))) continue;
|
|
149
|
+
if (attempt < 3 && (await reclaimAbandonedLock(lock, judged))) continue;
|
|
124
150
|
throw new FleetLockError(
|
|
125
151
|
'Fleet sync is locked. Inspect running installers before removing an abandoned sync.lock directory, then rerun.'
|
|
126
152
|
);
|
|
127
153
|
}
|
|
128
154
|
}
|
|
155
|
+
const owner =
|
|
156
|
+
JSON.stringify({
|
|
157
|
+
version: 1,
|
|
158
|
+
pid: process.pid,
|
|
159
|
+
startedAt: new Date().toISOString(),
|
|
160
|
+
run: randomUUID(),
|
|
161
|
+
}) + '\n';
|
|
129
162
|
try {
|
|
130
|
-
await writeFile(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
pid: process.pid,
|
|
135
|
-
startedAt: new Date().toISOString(),
|
|
136
|
-
}) + '\n',
|
|
137
|
-
{ flag: 'wx', mode: 0o600 }
|
|
138
|
-
);
|
|
163
|
+
await writeFile(join(lock, 'owner.json'), owner, {
|
|
164
|
+
flag: 'wx',
|
|
165
|
+
mode: 0o600,
|
|
166
|
+
});
|
|
139
167
|
await sweepTransferBundles(root);
|
|
140
168
|
return await operation();
|
|
141
169
|
} finally {
|
|
142
|
-
await
|
|
170
|
+
await releaseLock(lock, owner);
|
|
143
171
|
}
|
|
144
172
|
}
|
|
145
173
|
|
|
@@ -156,6 +184,30 @@ export async function ensurePrivateRoot(root: string): Promise<void> {
|
|
|
156
184
|
}
|
|
157
185
|
}
|
|
158
186
|
|
|
187
|
+
function validWorkers(value: unknown): boolean {
|
|
188
|
+
return (
|
|
189
|
+
value === undefined ||
|
|
190
|
+
(record(value) &&
|
|
191
|
+
Object.entries(value).every(
|
|
192
|
+
([id, revision]) =>
|
|
193
|
+
/^[a-z][a-z0-9-]{0,62}$/.test(id) && isRevision(revision)
|
|
194
|
+
))
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validRetired(value: unknown): boolean {
|
|
199
|
+
return (
|
|
200
|
+
value === undefined ||
|
|
201
|
+
(record(value) &&
|
|
202
|
+
Object.entries(value).every(
|
|
203
|
+
([id, at]) =>
|
|
204
|
+
/^[a-z][a-z0-9-]{0,62}$/.test(id) &&
|
|
205
|
+
typeof at === 'string' &&
|
|
206
|
+
!Number.isNaN(Date.parse(at))
|
|
207
|
+
))
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
159
211
|
/** A valid enrollment record, or null. */
|
|
160
212
|
export function parseEnrollment(data: unknown): Enrollment | null {
|
|
161
213
|
if (!record(data) || data['version'] !== 1) return null;
|
|
@@ -167,7 +219,9 @@ export function parseEnrollment(data: unknown): Enrollment | null {
|
|
|
167
219
|
return isRevision(data['approvedRevision']) &&
|
|
168
220
|
['appliedRevision', 'previousRevision'].every(
|
|
169
221
|
(key) => data[key] === undefined || isRevision(data[key])
|
|
170
|
-
)
|
|
222
|
+
) &&
|
|
223
|
+
validWorkers(data['workers']) &&
|
|
224
|
+
validRetired(data['retired'])
|
|
171
225
|
? (data as unknown as Enrollment)
|
|
172
226
|
: null;
|
|
173
227
|
}
|