@crvouga/dev-fleet 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -9
- package/agents/audit.md +18 -18
- package/ansible/agents/agents.yml +134 -0
- package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
- package/ansible/agents/templates/claude.settings.json.j2 +3 -0
- package/ansible/agents/templates/codex.config.toml.j2 +11 -0
- package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
- package/ansible/agents/templates/grok.config.toml.j2 +19 -0
- package/ansible/agents/templates/opencode.json.j2 +24 -0
- package/ansible/agents/templates/receipt.json.j2 +10 -0
- package/ansible/ansible.cfg +14 -0
- package/ansible/github/git.yml +82 -0
- package/ansible/macos/manager-privilege.yml +55 -9
- package/ansible/macos/manager.yml +154 -7
- package/ansible/macos/policy.json +13 -0
- package/ansible/macos/t3-policy.yml +5 -3
- package/ansible/macos/vault-renew.yml +85 -0
- package/ansible/monitoring/agent-darwin.yml +3 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/monitoring/hub-darwin.yml +3 -0
- package/ansible/monitoring/hub-route-darwin.yml +1 -0
- package/ansible/net/remote-probe.sh +4 -1
- package/ansible/platforms.json +2 -2
- package/ansible/pyproject.toml +5 -0
- package/ansible/ubuntu/baseline.yml +39 -13
- package/ansible/ubuntu/disk-gc.py +6 -2
- package/ansible/ubuntu/firewall.nft.j2 +50 -0
- package/ansible/ubuntu/policy.json +125 -6
- package/ansible/ubuntu/reboot.yml +89 -0
- package/ansible/ubuntu/recovery.yml +13 -3
- package/ansible/ubuntu/t3/policy.json +3 -0
- package/ansible/ubuntu/t3-desktop.yml +23 -2
- package/ansible/ubuntu/t3-headless.yml +128 -0
- package/ansible/ubuntu/t3-health.sh +24 -10
- package/ansible/ubuntu/t3-idle.py +37 -2
- package/ansible/ubuntu/t3-policy.yml +4 -1
- package/ansible/ubuntu/t3-route.yml +1 -0
- package/ansible/ubuntu/tailscale.yml +54 -8
- package/ansible/ubuntu/tasks/browser.yml +3 -0
- package/ansible/ubuntu/tasks/containers-off.yml +43 -0
- package/ansible/ubuntu/tasks/docker.yml +53 -6
- package/ansible/ubuntu/tasks/dpkg.yml +4 -0
- package/ansible/ubuntu/tasks/features.yml +22 -0
- package/ansible/ubuntu/tasks/firewall.yml +172 -0
- package/ansible/ubuntu/tasks/install.yml +2 -1
- package/ansible/ubuntu/tasks/packages.yml +4 -2
- package/ansible/ubuntu/tasks/release.yml +6 -0
- package/ansible/ubuntu/tasks/resources.yml +6 -4
- package/ansible/ubuntu/tasks/services.yml +2 -2
- package/ansible/ubuntu/tasks/sizing.yml +49 -0
- package/ansible/ubuntu/tasks/snapshot.yml +14 -3
- package/ansible/ubuntu/tasks/ssh.yml +61 -4
- package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
- package/ansible/ubuntu/tasks/user.yml +1 -0
- package/ansible/ubuntu/worker-account.yml +117 -0
- package/ansible/uv.lock +265 -0
- package/bin/devfleet.mjs +42 -4
- package/bootstrap/fleet-remote-bootstrap.sh +91 -25
- package/bootstrap/global-config.ts +29 -3
- package/bootstrap/package-smoke.ts +127 -0
- package/bootstrap/pins.ts +189 -0
- package/bootstrap/release-notes.ts +88 -0
- package/bootstrap/release-version.ts +41 -7
- package/bootstrap/worker-features.ts +114 -0
- package/bootstrap/worker-seed.yaml +7 -3
- package/bootstrap/worker-tools.ts +39 -9
- package/cliproxy/README.md +1 -1
- package/cliproxy/docs/health-workflow.example.yml +20 -143
- package/docs/authentication.md +19 -11
- package/docs/consumer-setup.md +42 -27
- package/docs/convergence.md +67 -0
- package/docs/declared.md +61 -34
- package/docs/machine-setup.md +73 -7
- package/docs/manager-setup.md +10 -3
- package/docs/operations.md +64 -9
- package/docs/releasing.md +49 -1
- package/docs/runbooks.md +98 -7
- package/docs/state.md +23 -11
- package/docs/support-matrix.md +40 -4
- package/docs/topology.md +67 -6
- package/docs/ubuntu-sync.md +78 -28
- package/docs/worker-contract.md +122 -19
- package/docs/worker-requirements.md +107 -0
- package/examples/fleet.json +1 -0
- package/examples/workers-from-infrastructure/main.tf +132 -0
- package/examples/workers-from-infrastructure/worker-records.json +14 -0
- package/llms.txt +3 -1
- package/mise.lock +29 -0
- package/mise.toml +5 -3
- package/package.json +2 -2
- package/schema/check-ids.json +1967 -170
- package/schema/check-ids.schema.json +34 -0
- package/schema/fleet.schema.json +327 -8
- package/schema/output/check.schema.json +1 -1
- package/schema/output/fleet-state-definition.schema.json +14 -0
- package/schema/output/fleet-sync-schedule.schema.json +26 -0
- package/schema/output/fleet-sync.schema.json +18 -0
- package/schema/output/report.schema.json +3 -2
- package/schema/worker-record.schema.json +76 -0
- package/src/agents/converge.ts +140 -24
- package/src/agents/files.ts +2 -32
- package/src/agents/native-verify.ts +34 -3
- package/src/agents/secrets.ts +39 -4
- package/src/check-owners.ts +61 -2
- package/src/check-registry.ts +630 -0
- package/src/cli/agent-guide.ts +3 -3
- package/src/cli/cli.ts +4 -0
- package/src/cli/fleet-manifest.ts +9 -3
- package/src/cli/fleet-node-lifecycle.ts +313 -64
- package/src/cli/fleet-node-park.ts +238 -0
- package/src/cli/fleet-node-seed.ts +51 -35
- package/src/cli/fleet-state.ts +2 -1
- package/src/cli/fleet-sync-checkout.ts +7 -0
- package/src/cli/fleet-sync-enrollment.ts +193 -32
- package/src/cli/fleet-sync-forward.ts +4 -0
- package/src/cli/fleet-sync-manager.ts +103 -33
- package/src/cli/fleet-sync-output.ts +31 -11
- package/src/cli/fleet-sync-plan.ts +65 -9
- package/src/cli/fleet-sync-records.ts +113 -0
- package/src/cli/fleet-sync-remote.ts +151 -9
- package/src/cli/fleet-sync-schedule.ts +14 -4
- package/src/cli/fleet-sync-state.ts +28 -16
- package/src/cli/fleet-sync-vault.ts +77 -0
- package/src/cli/fleet-sync-worker.ts +214 -0
- package/src/cli/fleet-sync.ts +150 -19
- package/src/cli/fleet-validate.ts +59 -21
- package/src/cli/fleet-watch.ts +7 -1
- package/src/cli/fleet.ts +87 -5
- package/src/cli/managed-health.ts +25 -1
- package/src/cli/register-fleet.ts +115 -4
- package/src/cli/registry.ts +14 -0
- package/src/cli/tailnet.ts +43 -10
- package/src/cli/ui-data.ts +3 -1
- package/src/cli/worker-usage-report.ts +63 -0
- package/src/clock.ts +42 -0
- package/src/definition.ts +332 -9
- package/src/diagnostics.ts +5 -2
- package/src/exposure.ts +102 -0
- package/src/fleet-config.ts +3 -1
- package/src/git-identity.ts +76 -3
- package/src/ide/ide.ts +5 -0
- package/src/ide/t3.ts +12 -2
- package/src/input-hashes.ts +6 -0
- package/src/machine/machine-class.ts +59 -0
- package/src/machine/platform.ts +90 -11
- package/src/machine/state.ts +37 -15
- package/src/machine/support-matrix.ts +11 -9
- package/src/machine-health.ts +28 -5
- package/src/manager-health.ts +148 -2
- package/src/manager-sync.ts +65 -4
- package/src/monitoring/beszel.ts +8 -8
- package/src/monitoring/enroll.ts +8 -1
- package/src/monitoring/hub-access.ts +10 -4
- package/src/monitoring/systems.ts +138 -0
- package/src/network-definition.ts +13 -4
- package/src/node-converge-linux.ts +136 -27
- package/src/node-converge.ts +13 -3
- package/src/platform/ansible-receipt.ts +17 -2
- package/src/platform/ansible.ts +40 -4
- package/src/platform/manager-host.ts +25 -8
- package/src/platform/resource-verify.ts +18 -7
- package/src/platform/t3-policy.ts +24 -10
- package/src/platform/t3-service.ts +109 -34
- package/src/platform/ubuntu-release.ts +24 -0
- package/src/platform/ubuntu-verify.ts +31 -3
- package/src/platform/ubuntu-worker.ts +58 -0
- package/src/platform/worker-platform.ts +72 -0
- package/src/protocol.ts +1 -1
- package/src/public-reachability.ts +186 -0
- package/src/remote-bootstrap.ts +50 -12
- package/src/remote-operation.ts +18 -4
- package/src/repair-plan.ts +23 -12
- package/src/report.ts +2 -1
- package/src/schedule-check.ts +21 -2
- package/src/secrets.ts +4 -0
- package/src/service-auth.ts +55 -48
- package/src/ssh-connection.ts +141 -0
- package/src/ssh.ts +63 -24
- package/src/state/store.ts +4 -2
- package/src/state/sync.ts +35 -6
- package/src/state/workers.ts +140 -0
- package/src/t3/pairing.ts +59 -0
- package/src/t3/peer-link.ts +333 -0
- package/src/t3/version.ts +1 -1
- package/src/tailnet-cleanup.ts +185 -0
- package/src/tailnet-control.ts +346 -0
- package/src/tailscale.ts +1 -1
- package/src/toolchain-checks.ts +11 -1
- package/src/types.ts +4 -2
- package/src/worker-account.ts +63 -0
- package/src/worker-activity.ts +303 -0
- package/src/worker-capability-checks.ts +55 -16
- package/src/worker-capacity.ts +31 -0
- package/src/worker-exposure.ts +388 -0
- package/src/worker-features.ts +201 -0
- package/src/worker-host-checks.ts +71 -20
- package/src/worker-manifest.ts +27 -4
- package/src/worker-parking.ts +70 -0
- package/src/worker-policy.ts +17 -1
- package/src/worker-readiness.ts +15 -3
- package/src/worker-reboot.ts +129 -0
- package/src/worker-records.ts +305 -0
- package/src/worker-seed.ts +16 -11
- package/src/worker-sizing.ts +76 -0
- package/src/cli/schedule-logs.ts +0 -36
package/src/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
|
@@ -86,20 +86,39 @@ async function lockAbandoned(lock: string): Promise<boolean> {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/** Removes an abandoned lock.
|
|
90
|
-
*
|
|
91
|
-
|
|
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> {
|
|
92
98
|
if (!(await lockAbandoned(lock))) return false;
|
|
99
|
+
await judged();
|
|
93
100
|
const aside = `${lock}.abandoned-${randomUUID()}`;
|
|
94
101
|
try {
|
|
95
102
|
await rename(lock, aside);
|
|
96
103
|
} catch {
|
|
97
104
|
return true; // Someone else already recovered it; retry.
|
|
98
105
|
}
|
|
106
|
+
if (!(await lockAbandoned(aside))) {
|
|
107
|
+
await rename(aside, lock).catch(() => undefined);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
99
110
|
await rm(aside, { recursive: true, force: true });
|
|
100
111
|
return true;
|
|
101
112
|
}
|
|
102
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
|
+
|
|
103
122
|
/** Bundles are written next to the lock and removed in a `finally`, which a
|
|
104
123
|
* killed run never reaches. Holding the lock means none is in use. */
|
|
105
124
|
async function sweepTransferBundles(root: string): Promise<void> {
|
|
@@ -113,7 +132,8 @@ async function sweepTransferBundles(root: string): Promise<void> {
|
|
|
113
132
|
* cleanup. Never steals a lock from a still-running installer. */
|
|
114
133
|
export async function withMachineLock<T>(
|
|
115
134
|
root: string,
|
|
116
|
-
operation: () => Promise<T
|
|
135
|
+
operation: () => Promise<T>,
|
|
136
|
+
judged: () => Promise<void> = async () => {}
|
|
117
137
|
): Promise<T> {
|
|
118
138
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
119
139
|
const lock = join(root, 'sync.lock');
|
|
@@ -126,26 +146,28 @@ export async function withMachineLock<T>(
|
|
|
126
146
|
throw new FleetUsageError(
|
|
127
147
|
'Cannot create manager lock; inspect private state-directory permissions and free disk space.'
|
|
128
148
|
);
|
|
129
|
-
if (attempt < 3 && (await reclaimAbandonedLock(lock))) continue;
|
|
149
|
+
if (attempt < 3 && (await reclaimAbandonedLock(lock, judged))) continue;
|
|
130
150
|
throw new FleetLockError(
|
|
131
151
|
'Fleet sync is locked. Inspect running installers before removing an abandoned sync.lock directory, then rerun.'
|
|
132
152
|
);
|
|
133
153
|
}
|
|
134
154
|
}
|
|
155
|
+
const owner =
|
|
156
|
+
JSON.stringify({
|
|
157
|
+
version: 1,
|
|
158
|
+
pid: process.pid,
|
|
159
|
+
startedAt: new Date().toISOString(),
|
|
160
|
+
run: randomUUID(),
|
|
161
|
+
}) + '\n';
|
|
135
162
|
try {
|
|
136
|
-
await writeFile(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
pid: process.pid,
|
|
141
|
-
startedAt: new Date().toISOString(),
|
|
142
|
-
}) + '\n',
|
|
143
|
-
{ flag: 'wx', mode: 0o600 }
|
|
144
|
-
);
|
|
163
|
+
await writeFile(join(lock, 'owner.json'), owner, {
|
|
164
|
+
flag: 'wx',
|
|
165
|
+
mode: 0o600,
|
|
166
|
+
});
|
|
145
167
|
await sweepTransferBundles(root);
|
|
146
168
|
return await operation();
|
|
147
169
|
} finally {
|
|
148
|
-
await
|
|
170
|
+
await releaseLock(lock, owner);
|
|
149
171
|
}
|
|
150
172
|
}
|
|
151
173
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import macos from '../../ansible/macos/policy.json';
|
|
2
2
|
import platforms from '../../ansible/platforms.json';
|
|
3
|
-
import {
|
|
3
|
+
import { WORKER_PLATFORMS } from '../platform/ubuntu-worker';
|
|
4
4
|
|
|
5
5
|
export type SupportStatus = 'supported' | 'best effort' | 'refused';
|
|
6
6
|
export interface SupportRow {
|
|
@@ -25,14 +25,16 @@ export function supportMatrix(): SupportRow[] {
|
|
|
25
25
|
status: 'supported',
|
|
26
26
|
note: 'ansible/macos/policy.json',
|
|
27
27
|
},
|
|
28
|
-
...
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
...WORKER_PLATFORMS.flatMap((platform) =>
|
|
29
|
+
platform.releases.map((entry): SupportRow => ({
|
|
30
|
+
role: 'worker',
|
|
31
|
+
platform: platform.label,
|
|
32
|
+
versions: `${entry.release} (${entry.codename})`,
|
|
33
|
+
architectures: entry.architectures.join(', '),
|
|
34
|
+
status: 'supported',
|
|
35
|
+
note: `${platform.policy}; a machine of its own, physical or virtual`,
|
|
36
|
+
}))
|
|
37
|
+
),
|
|
36
38
|
...platforms.rows.map((row): SupportRow => ({
|
|
37
39
|
role: row.role,
|
|
38
40
|
platform: row.platform,
|
package/src/machine-health.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import policy from '../ansible/ubuntu/policy.json';
|
|
2
2
|
import type { DoctorCheck } from './doctor-check';
|
|
3
|
+
import { threshold } from './worker-sizing';
|
|
3
4
|
|
|
4
5
|
/** Machine health sample parsed from `net/remote-probe.sh` output. */
|
|
5
6
|
export type HealthSample = {
|
|
@@ -8,6 +9,12 @@ export type HealthSample = {
|
|
|
8
9
|
readonly memFreePct?: number;
|
|
9
10
|
readonly swapPct?: number;
|
|
10
11
|
readonly diskMb?: number;
|
|
12
|
+
/** The size of the home filesystem, in MiB. */
|
|
13
|
+
readonly diskTotalMb?: number;
|
|
14
|
+
/** All memory, in MiB. */
|
|
15
|
+
readonly memTotalMb?: number;
|
|
16
|
+
/** Agent sessions running now: T3's agent scopes. */
|
|
17
|
+
readonly agentSessions?: number;
|
|
11
18
|
/** Free inodes on the home filesystem, as a percentage of all of them. */
|
|
12
19
|
readonly inodesFreePct?: number;
|
|
13
20
|
/** 1 when the clock is synchronised to a time source, 0 when not. */
|
|
@@ -24,6 +31,9 @@ const PROBE_KEYS = {
|
|
|
24
31
|
memfree: 'memFreePct',
|
|
25
32
|
swap: 'swapPct',
|
|
26
33
|
diskmb: 'diskMb',
|
|
34
|
+
disktotalmb: 'diskTotalMb',
|
|
35
|
+
memtotalmb: 'memTotalMb',
|
|
36
|
+
agentsessions: 'agentSessions',
|
|
27
37
|
inodefree: 'inodesFreePct',
|
|
28
38
|
ntpsync: 'clockSynced',
|
|
29
39
|
ntp: 'timeService',
|
|
@@ -52,6 +62,8 @@ const LABELS = {
|
|
|
52
62
|
disk: 'Disk space',
|
|
53
63
|
inodes: 'Free inodes',
|
|
54
64
|
};
|
|
65
|
+
/** The capacity checks of a machine, each reported as `worker:capacity:<id>`. */
|
|
66
|
+
export const HEALTH_CHECK_IDS = Object.keys(LABELS);
|
|
55
67
|
|
|
56
68
|
function check(
|
|
57
69
|
id: keyof typeof LABELS,
|
|
@@ -92,17 +104,28 @@ export function memoryCheck(s: HealthSample): DoctorCheck | null {
|
|
|
92
104
|
return check('memory', 'pass', what);
|
|
93
105
|
}
|
|
94
106
|
|
|
95
|
-
/** Free disk
|
|
96
|
-
|
|
107
|
+
/** Free disk, against lines that scale with the disk
|
|
108
|
+
* (`sizing.thresholds.capacityFail` and `capacityWarn` in policy.json): under
|
|
109
|
+
* the first fails (VS Code server, git, worktrees break), under the second
|
|
110
|
+
* warns. A disk of unknown size gets the upper bounds.
|
|
111
|
+
*/
|
|
112
|
+
export function diskCheck(
|
|
113
|
+
diskMb: number | undefined,
|
|
114
|
+
diskTotalMb?: number
|
|
115
|
+
): DoctorCheck | null {
|
|
97
116
|
if (diskMb === undefined) return null;
|
|
98
|
-
|
|
117
|
+
const total = diskTotalMb ?? Number.MAX_SAFE_INTEGER;
|
|
118
|
+
const fail = threshold('capacityFail', total);
|
|
119
|
+
const warn = threshold('capacityWarn', total);
|
|
120
|
+
if (diskMb < fail) {
|
|
99
121
|
return check(
|
|
100
122
|
'disk',
|
|
101
123
|
'fail',
|
|
102
124
|
`almost full, ${diskMb} MB free. VS Code server, git and T3 worktrees will break.`
|
|
103
125
|
);
|
|
104
126
|
}
|
|
105
|
-
if (diskMb <
|
|
127
|
+
if (diskMb < warn)
|
|
128
|
+
return check('disk', 'warn', `low, ${diskMb} MB free (under ${warn} MB)`);
|
|
106
129
|
return check('disk', 'pass', `${Math.floor(diskMb / 1024)} GB free`);
|
|
107
130
|
}
|
|
108
131
|
|
|
@@ -136,7 +159,7 @@ export function healthChecks(s: HealthSample, alias: string): DoctorCheck[] {
|
|
|
136
159
|
return [
|
|
137
160
|
loadCheck(s, alias),
|
|
138
161
|
memoryCheck(s),
|
|
139
|
-
diskCheck(s.diskMb),
|
|
162
|
+
diskCheck(s.diskMb, s.diskTotalMb),
|
|
140
163
|
inodesCheck(s.inodesFreePct),
|
|
141
164
|
].filter((c): c is DoctorCheck => c !== null);
|
|
142
165
|
}
|