@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
|
@@ -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
|
}
|
package/src/manager-health.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { homedir, userInfo } from 'node:os';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import macos from '../ansible/macos/policy.json';
|
|
4
5
|
import type { Check, Runner } from './types';
|
|
@@ -9,16 +10,161 @@ import type { Check, Runner } from './types';
|
|
|
9
10
|
*/
|
|
10
11
|
export async function managerHealth(
|
|
11
12
|
run: Runner,
|
|
12
|
-
home = homedir()
|
|
13
|
+
home = homedir(),
|
|
14
|
+
{ uid, user }: { uid: number; user: string } = {
|
|
15
|
+
uid: userInfo().uid,
|
|
16
|
+
user: userInfo().username,
|
|
17
|
+
}
|
|
13
18
|
): Promise<Check[]> {
|
|
14
19
|
return [
|
|
15
20
|
await managerRelease(run),
|
|
16
21
|
await managerUpdates(run),
|
|
17
22
|
await managerDisk(run),
|
|
18
23
|
await containerWatchdog(run, home),
|
|
24
|
+
await managerClock(run),
|
|
25
|
+
await managerWatch(run, uid),
|
|
26
|
+
await managerJobLogs(run, home, user),
|
|
19
27
|
];
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
/** The one grant every administrator step of the manager is given with. */
|
|
31
|
+
const GRANT =
|
|
32
|
+
'ansible-playbook -e user=$(id -un) -K ansible/macos/manager-privilege.yml';
|
|
33
|
+
|
|
34
|
+
/** The manager keeps network time: every worker's clock is measured against
|
|
35
|
+
* it. Reading the setting needs root, through the grant manager-privilege.yml
|
|
36
|
+
* installs; manager.yml asserts the same reading.
|
|
37
|
+
*/
|
|
38
|
+
export async function managerClock(run: Runner): Promise<Check> {
|
|
39
|
+
const result = await run(
|
|
40
|
+
['sudo', '-n', '/usr/sbin/systemsetup', '-getusingnetworktime'],
|
|
41
|
+
{ timeoutMs: 10_000 }
|
|
42
|
+
);
|
|
43
|
+
const on = /Network Time:\s*On\b/i.test(result.stdout);
|
|
44
|
+
const off = /Network Time:\s*Off\b/i.test(result.stdout);
|
|
45
|
+
if (!result.ok || (!on && !off))
|
|
46
|
+
return {
|
|
47
|
+
id: 'manager:clock',
|
|
48
|
+
status: 'warn',
|
|
49
|
+
summary: 'MANAGER_CLOCK_UNOBSERVED',
|
|
50
|
+
remedy: {
|
|
51
|
+
action: `Reading whether network time is on needs the manager's grant. Give it once, as an administrator, from the dev-fleet checkout fleet-sync runs: ${GRANT}`,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
if (on)
|
|
55
|
+
return {
|
|
56
|
+
id: 'manager:clock',
|
|
57
|
+
status: 'pass',
|
|
58
|
+
summary: 'The manager keeps its clock on network time.',
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
id: 'manager:clock',
|
|
62
|
+
status: 'fail',
|
|
63
|
+
summary: 'MANAGER_CLOCK_NOT_SYNCHRONISED',
|
|
64
|
+
remedy: {
|
|
65
|
+
action:
|
|
66
|
+
'Turn on Set time and date automatically in System Settings > General > Date & Time (sudo systemsetup -setusingnetworktime on). Every worker clock is measured against the manager.',
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** fleet-watch runs on a schedule as the launchd job manager.yml installs:
|
|
72
|
+
* without it no heartbeat is stored and no notification is sent.
|
|
73
|
+
*/
|
|
74
|
+
export async function managerWatch(run: Runner, uid: number): Promise<Check> {
|
|
75
|
+
const label = macos.fleetWatch.label;
|
|
76
|
+
const result = await run(['/bin/launchctl', 'print', `gui/${uid}/${label}`], {
|
|
77
|
+
timeoutMs: 10_000,
|
|
78
|
+
});
|
|
79
|
+
if (!result.ok)
|
|
80
|
+
return {
|
|
81
|
+
id: 'manager:watch',
|
|
82
|
+
status: 'fail',
|
|
83
|
+
summary: 'MANAGER_WATCH_NOT_LOADED',
|
|
84
|
+
remedy: {
|
|
85
|
+
action: `Run devfleet fleet-sync on the manager: it installs and loads ${label} with ansible/macos/manager.yml. Until then no heartbeat reaches the bucket, and the heartbeat workflow fails.`,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
// launchd keeps how the last run ended; a job that never ran has none.
|
|
89
|
+
const exit = /last exit code = (\d+)/.exec(result.stdout)?.[1];
|
|
90
|
+
if (exit !== undefined && exit !== '0')
|
|
91
|
+
return {
|
|
92
|
+
id: 'manager:watch',
|
|
93
|
+
status: 'warn',
|
|
94
|
+
summary: `MANAGER_WATCH_FAILING exit ${exit}`,
|
|
95
|
+
remedy: {
|
|
96
|
+
action: `Its last run failed. Read ~/${macos.jobLogs.directory}/${label}.err.log, and run devfleet fleet-watch on the manager to see the same failure.`,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
id: 'manager:watch',
|
|
101
|
+
status: 'pass',
|
|
102
|
+
summary: `fleet-watch runs on a schedule (${label}).`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The logs of the manager's scheduled jobs, the size newsyslog keeps them
|
|
107
|
+
* under, and the drop-in manager-privilege.yml installs for it.
|
|
108
|
+
*/
|
|
109
|
+
export function jobLogs(home: string): string[] {
|
|
110
|
+
return [macos.fleetSync.label, macos.fleetWatch.label].flatMap((label) =>
|
|
111
|
+
['out', 'err'].map((stream) =>
|
|
112
|
+
join(home, macos.jobLogs.directory, `${label}.${stream}.log`)
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const newsyslogDropIn = (user: string): string =>
|
|
118
|
+
`/etc/newsyslog.d/dev-fleet-${user}.conf`;
|
|
119
|
+
|
|
120
|
+
/** The logs of the scheduled jobs stay under a fixed size: the newsyslog
|
|
121
|
+
* drop-in that rotates them is in place, names each of them, and none has
|
|
122
|
+
* grown past twice the size it is rotated at (newsyslog runs hourly).
|
|
123
|
+
*/
|
|
124
|
+
export async function managerJobLogs(
|
|
125
|
+
run: Runner,
|
|
126
|
+
home: string,
|
|
127
|
+
user: string
|
|
128
|
+
): Promise<Check> {
|
|
129
|
+
const logs = jobLogs(home);
|
|
130
|
+
const dropIn = await run(['cat', newsyslogDropIn(user)]);
|
|
131
|
+
const declared =
|
|
132
|
+
dropIn.ok &&
|
|
133
|
+
dropIn.stdout.includes('# Managed by dev-fleet: manager job logs') &&
|
|
134
|
+
logs.every((log) => dropIn.stdout.includes(`${log} `));
|
|
135
|
+
if (!declared)
|
|
136
|
+
return {
|
|
137
|
+
id: 'manager:job-logs',
|
|
138
|
+
status: 'warn',
|
|
139
|
+
summary: 'MANAGER_JOB_LOG_ROTATION_MISSING',
|
|
140
|
+
remedy: {
|
|
141
|
+
action: `newsyslog keeps the logs of the scheduled jobs under ${macos.jobLogs.maxKiB} KiB once the manager's grant installs its drop-in. Give it once, as an administrator, from the dev-fleet checkout fleet-sync runs: ${GRANT}`,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
const limit = 2 * macos.jobLogs.maxKiB * 1024;
|
|
145
|
+
const large = logs.filter((log) => {
|
|
146
|
+
try {
|
|
147
|
+
return statSync(log).size > limit;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
if (large.length)
|
|
153
|
+
return {
|
|
154
|
+
id: 'manager:job-logs',
|
|
155
|
+
status: 'warn',
|
|
156
|
+
summary: `MANAGER_JOB_LOG_OVERSIZED ${large.length} logs`,
|
|
157
|
+
remedy: {
|
|
158
|
+
action: `newsyslog rotates these logs once they pass ${macos.jobLogs.maxKiB} KiB but has not: check that it runs (sudo newsyslog -nv -f ${newsyslogDropIn(user)}) and read /var/log/system.log for its errors.`,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return {
|
|
162
|
+
id: 'manager:job-logs',
|
|
163
|
+
status: 'pass',
|
|
164
|
+
summary: `newsyslog keeps the scheduled jobs' logs under ${macos.jobLogs.maxKiB} KiB.`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
22
168
|
/** The manager runs at least the macOS release the policy names. */
|
|
23
169
|
export async function managerRelease(run: Runner): Promise<Check> {
|
|
24
170
|
const id = 'manager:release';
|
package/src/manager-sync.ts
CHANGED
|
@@ -12,14 +12,19 @@ import {
|
|
|
12
12
|
type TailnetComputer,
|
|
13
13
|
} from './tailscale';
|
|
14
14
|
import { matchComputers } from './tailnet-matching';
|
|
15
|
+
import { rebootStopsRollout } from './worker-reboot';
|
|
15
16
|
import { sshArgs, sshFailure } from './ssh';
|
|
16
17
|
import type { FleetDefinition, MachineDefinition } from './definition';
|
|
17
18
|
import { publicHubUrl } from './monitoring/beszel';
|
|
18
19
|
import {
|
|
19
20
|
evaluateEnvironment,
|
|
21
|
+
evaluateMachineClass,
|
|
20
22
|
evaluatePlatform,
|
|
23
|
+
evaluateSize,
|
|
21
24
|
PLATFORM_PROBE,
|
|
25
|
+
probedClock,
|
|
22
26
|
} from './machine/platform';
|
|
27
|
+
import { clockOffsetCheck } from './clock';
|
|
23
28
|
|
|
24
29
|
export interface ManagerNode extends NodeReport {
|
|
25
30
|
/** The singleton manager converges locally; workers over SSH. */
|
|
@@ -37,6 +42,12 @@ export interface ManagerReport {
|
|
|
37
42
|
ok: boolean;
|
|
38
43
|
applied: boolean;
|
|
39
44
|
desiredRevision: string | null;
|
|
45
|
+
/** The worker that proved a revision new to the fleet first, when one did. */
|
|
46
|
+
canary?: string;
|
|
47
|
+
/** The workers this run enrolled (added to the roster, or bound to their
|
|
48
|
+
* tailnet device).
|
|
49
|
+
*/
|
|
50
|
+
enrolled?: string[];
|
|
40
51
|
checks: Check[];
|
|
41
52
|
nodes: ManagerNode[];
|
|
42
53
|
candidates: number;
|
|
@@ -47,17 +58,30 @@ export type DeployNode = (
|
|
|
47
58
|
revision: string
|
|
48
59
|
) => Promise<NodeReport>;
|
|
49
60
|
|
|
50
|
-
/** Minimal read-only POSIX inspection requires no workspace, Node, Bun or Git.
|
|
61
|
+
/** Minimal read-only POSIX inspection requires no workspace, Node, Bun or Git.
|
|
62
|
+
* Given the manager's clock, it also measures the worker's clock against it
|
|
63
|
+
* (`worker:clock-offset`).
|
|
64
|
+
*/
|
|
51
65
|
export async function inspectBootstrap(
|
|
52
66
|
node: FleetNode,
|
|
53
|
-
run: Runner
|
|
67
|
+
run: Runner,
|
|
68
|
+
clock?: () => number
|
|
54
69
|
): Promise<Check[]> {
|
|
70
|
+
const sentAt = clock?.();
|
|
55
71
|
const result = await run([...sshArgs(node), PLATFORM_PROBE], {
|
|
56
72
|
timeoutMs: 20_000,
|
|
57
73
|
});
|
|
74
|
+
const receivedAt = clock?.();
|
|
58
75
|
if (!result.ok) return [sshFailure(result)];
|
|
59
76
|
const baseline = evaluatePlatform(result.stdout);
|
|
60
77
|
const environment = evaluateEnvironment(result.stdout);
|
|
78
|
+
const remote = probedClock(result.stdout);
|
|
79
|
+
const offset =
|
|
80
|
+
remote === undefined || sentAt === undefined || receivedAt === undefined
|
|
81
|
+
? []
|
|
82
|
+
: [clockOffsetCheck(remote, sentAt, receivedAt)];
|
|
83
|
+
const machine = evaluateMachineClass(result.stdout);
|
|
84
|
+
const size = evaluateSize(result.stdout);
|
|
61
85
|
return [
|
|
62
86
|
{
|
|
63
87
|
id: 'ssh:fresh-session',
|
|
@@ -83,6 +107,25 @@ export async function inspectBootstrap(
|
|
|
83
107
|
'Run the worker on a physical or virtual machine of its own; docs/support-matrix.md lists what is supported.',
|
|
84
108
|
},
|
|
85
109
|
},
|
|
110
|
+
{ id: machine.id, status: 'pass', summary: machine.summary },
|
|
111
|
+
...(size
|
|
112
|
+
? [
|
|
113
|
+
{
|
|
114
|
+
id: size.id,
|
|
115
|
+
status: size.supported ? ('pass' as const) : ('fail' as const),
|
|
116
|
+
summary: size.summary,
|
|
117
|
+
...(size.supported
|
|
118
|
+
? {}
|
|
119
|
+
: {
|
|
120
|
+
remedy: {
|
|
121
|
+
action:
|
|
122
|
+
'Resize the machine to at least the minimum worker size docs/worker-contract.md states, then run fleet-sync. Nothing was installed.',
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
},
|
|
126
|
+
]
|
|
127
|
+
: []),
|
|
128
|
+
...offset,
|
|
86
129
|
];
|
|
87
130
|
}
|
|
88
131
|
|
|
@@ -96,11 +139,17 @@ export async function synchronizeManager(
|
|
|
96
139
|
revision: string;
|
|
97
140
|
apply: boolean;
|
|
98
141
|
canary?: string;
|
|
142
|
+
/** One worker at a time, canary first; a worker a reboot left unready
|
|
143
|
+
* stops the ones after it.
|
|
144
|
+
*/
|
|
145
|
+
serial?: boolean;
|
|
99
146
|
progress?: (node: string, phase: string) => void;
|
|
100
147
|
/** Called once per node as soon as its result is final. */
|
|
101
148
|
finished?: (node: ManagerNode) => void;
|
|
102
149
|
/** Converges the manager locally; omitted in tests. */
|
|
103
150
|
manager?: () => Promise<ManagerNode>;
|
|
151
|
+
/** The manager's clock, in milliseconds; workers are measured against it. */
|
|
152
|
+
clock?: () => number;
|
|
104
153
|
},
|
|
105
154
|
run: Runner,
|
|
106
155
|
deploy: DeployNode
|
|
@@ -144,6 +193,7 @@ export async function synchronizeManager(
|
|
|
144
193
|
revision: input.revision,
|
|
145
194
|
computers: tailnet.computers,
|
|
146
195
|
matches,
|
|
196
|
+
clock: input.clock ?? Date.now,
|
|
147
197
|
},
|
|
148
198
|
run
|
|
149
199
|
);
|
|
@@ -190,18 +240,17 @@ async function applyNodes(
|
|
|
190
240
|
nodes: FleetNode[];
|
|
191
241
|
revision: string;
|
|
192
242
|
canary?: string;
|
|
243
|
+
serial?: boolean;
|
|
193
244
|
progress?: (node: string, phase: string) => void;
|
|
194
245
|
finished?: (node: ManagerNode) => void;
|
|
195
246
|
},
|
|
196
247
|
deploy: DeployNode
|
|
197
248
|
): Promise<void> {
|
|
198
249
|
const workers = report.nodes.filter((node) => node.role !== 'manager');
|
|
199
|
-
// A revision new to the fleet is proven on one worker first. When the run
|
|
200
|
-
// selects only later workers, the first selected one proves it instead:
|
|
201
|
-
// narrowing a run never skips the canary.
|
|
202
250
|
const canary = input.canary
|
|
203
|
-
? (workers
|
|
251
|
+
? chooseCanary(workers, input.canary, input.nodes)
|
|
204
252
|
: undefined;
|
|
253
|
+
if (canary) report.canary = canary.node;
|
|
205
254
|
let blocked = report.checks.some(blocking);
|
|
206
255
|
const step = async (result: ManagerNode) => {
|
|
207
256
|
const deployable = mayDeploy(result, input.nodes);
|
|
@@ -216,7 +265,36 @@ async function applyNodes(
|
|
|
216
265
|
const deployable = await step(canary);
|
|
217
266
|
if (!deployable || canary.checks.some(blocksPromotion)) blocked = true;
|
|
218
267
|
}
|
|
219
|
-
|
|
268
|
+
const rest = workers.filter((node) => node !== canary);
|
|
269
|
+
if (!input.serial) {
|
|
270
|
+
await Promise.all(rest.map(step));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (canary && rebootStopsRollout(canary)) blocked = true;
|
|
274
|
+
for (const node of rest) {
|
|
275
|
+
await step(node);
|
|
276
|
+
if (rebootStopsRollout(node)) blocked = true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** A revision new to the fleet is proven on one worker first: the preferred
|
|
281
|
+
* one, or when it cannot be reached (powered off, being rebuilt, offline)
|
|
282
|
+
* the next worker in declared order that can. An unreachable worker is
|
|
283
|
+
* reported as such and never blocks the others; only a canary that was
|
|
284
|
+
* deployed and failed stops promotion. When the run selects only later
|
|
285
|
+
* workers, the first selected one proves it instead: narrowing a run never
|
|
286
|
+
* skips the canary.
|
|
287
|
+
*/
|
|
288
|
+
function chooseCanary(
|
|
289
|
+
workers: ManagerNode[],
|
|
290
|
+
preferred: string,
|
|
291
|
+
nodes: FleetNode[]
|
|
292
|
+
): ManagerNode | undefined {
|
|
293
|
+
const first = workers.find((node) => node.node === preferred);
|
|
294
|
+
const order = first
|
|
295
|
+
? [first, ...workers.filter((node) => node !== first)]
|
|
296
|
+
: workers;
|
|
297
|
+
return order.find((node) => mayDeploy(node, nodes));
|
|
220
298
|
}
|
|
221
299
|
|
|
222
300
|
/** One node's rollout step; true when its remote operation ran. */
|
|
@@ -302,6 +380,7 @@ async function inspectManagerNode(
|
|
|
302
380
|
revision: string;
|
|
303
381
|
computers: TailnetComputer[];
|
|
304
382
|
matches: Map<string, FleetNode[]>;
|
|
383
|
+
clock: () => number;
|
|
305
384
|
},
|
|
306
385
|
run: Runner
|
|
307
386
|
): Promise<ManagerNode> {
|
|
@@ -337,7 +416,9 @@ async function inspectManagerNode(
|
|
|
337
416
|
},
|
|
338
417
|
});
|
|
339
418
|
else {
|
|
340
|
-
|
|
419
|
+
// fleet-sync runs on the manager: the worker's clock is measured
|
|
420
|
+
// against the manager's own.
|
|
421
|
+
checks.push(...(await inspectBootstrap(node, run, context.clock)));
|
|
341
422
|
checks.push(...identityChecks(node, peer));
|
|
342
423
|
if (peer) checks.push(keyExpiryCheck(peer));
|
|
343
424
|
}
|
package/src/monitoring/beszel.ts
CHANGED
|
@@ -27,7 +27,7 @@ function check(
|
|
|
27
27
|
summary: string,
|
|
28
28
|
action: string
|
|
29
29
|
): Check {
|
|
30
|
-
return { id
|
|
30
|
+
return { id, status, summary, remedy: { action } };
|
|
31
31
|
}
|
|
32
32
|
/** Public dashboard hostname served through the Cloudflare
|
|
33
33
|
* tunnel `beszel`. Agents and the manager keep using the private hub URL.
|
|
@@ -68,7 +68,7 @@ export async function verifyBeszel(
|
|
|
68
68
|
if (!object(info) || info['v'] !== lock.version) {
|
|
69
69
|
result.checks.push(
|
|
70
70
|
check(
|
|
71
|
-
'version',
|
|
71
|
+
'beszel:version',
|
|
72
72
|
'fail',
|
|
73
73
|
'BESZEL_VERSION_MISMATCH',
|
|
74
74
|
'Apply the pinned hub release before using its versioned API contract.'
|
|
@@ -78,7 +78,7 @@ export async function verifyBeszel(
|
|
|
78
78
|
}
|
|
79
79
|
result.checks.push(
|
|
80
80
|
check(
|
|
81
|
-
'version',
|
|
81
|
+
'beszel:version',
|
|
82
82
|
'pass',
|
|
83
83
|
`Authenticated hub reports ${lock.version}.`,
|
|
84
84
|
'None.'
|
|
@@ -93,7 +93,7 @@ export async function verifyBeszel(
|
|
|
93
93
|
if (records.length !== 1) {
|
|
94
94
|
result.checks.push(
|
|
95
95
|
check(
|
|
96
|
-
'registration',
|
|
96
|
+
'beszel:registration',
|
|
97
97
|
'fail',
|
|
98
98
|
records.length ? 'BESZEL_DUPLICATE_NODE' : 'BESZEL_NODE_MISSING',
|
|
99
99
|
'Review the canonical node in the hub. Preserve its fingerprint and history; do not create a replacement identity.'
|
|
@@ -105,7 +105,7 @@ export async function verifyBeszel(
|
|
|
105
105
|
} catch {
|
|
106
106
|
result.checks.push(
|
|
107
107
|
check(
|
|
108
|
-
'api',
|
|
108
|
+
'beszel:api',
|
|
109
109
|
'fail',
|
|
110
110
|
'BESZEL_API_UNVERIFIED',
|
|
111
111
|
'Verify private reachability and a valid hub account with access to this node. Raw HTTP bodies are withheld.'
|
|
@@ -147,7 +147,7 @@ async function verifySample(
|
|
|
147
147
|
throw new Error('BESZEL_PROTOCOL');
|
|
148
148
|
result.checks.push(
|
|
149
149
|
check(
|
|
150
|
-
'registration',
|
|
150
|
+
'beszel:registration',
|
|
151
151
|
'pass',
|
|
152
152
|
'Exactly one canonical node record is visible to this account.',
|
|
153
153
|
'None.'
|
|
@@ -155,7 +155,7 @@ async function verifySample(
|
|
|
155
155
|
);
|
|
156
156
|
result.checks.push(
|
|
157
157
|
check(
|
|
158
|
-
'connection',
|
|
158
|
+
'beszel:connection',
|
|
159
159
|
record['status'] === 'up' ? 'pass' : 'fail',
|
|
160
160
|
record['status'] === 'up'
|
|
161
161
|
? 'Hub reports node connected.'
|
|
@@ -187,7 +187,7 @@ async function checkFreshness(
|
|
|
187
187
|
: null;
|
|
188
188
|
result.checks.push(
|
|
189
189
|
check(
|
|
190
|
-
'freshness',
|
|
190
|
+
'beszel:freshness',
|
|
191
191
|
fresh ? 'pass' : 'fail',
|
|
192
192
|
fresh
|
|
193
193
|
? `Hub received sample ${result.receivedAt}; within 180 seconds.`
|
package/src/monitoring/enroll.ts
CHANGED
|
@@ -3,7 +3,14 @@ import { record } from '../config';
|
|
|
3
3
|
import { privateHub } from './beszel';
|
|
4
4
|
import lock from '../../ansible/monitoring/lock.json';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/** A call to the hub's API: GET without a body, POST with one, or the
|
|
7
|
+
* method named (PATCH a record, DELETE one).
|
|
8
|
+
*/
|
|
9
|
+
export type HubRequest = (
|
|
10
|
+
path: string,
|
|
11
|
+
body?: object,
|
|
12
|
+
method?: 'PATCH' | 'DELETE'
|
|
13
|
+
) => Promise<unknown>;
|
|
7
14
|
export interface AgentEnrollment {
|
|
8
15
|
systemId: string;
|
|
9
16
|
hubUrl: string;
|
|
@@ -71,7 +71,7 @@ export async function hubAccess(
|
|
|
71
71
|
url,
|
|
72
72
|
'/api/collections/users/auth-with-password',
|
|
73
73
|
'',
|
|
74
|
-
credentials
|
|
74
|
+
{ body: credentials }
|
|
75
75
|
);
|
|
76
76
|
if (
|
|
77
77
|
!record(login) ||
|
|
@@ -83,24 +83,30 @@ export async function hubAccess(
|
|
|
83
83
|
const token = login['token'];
|
|
84
84
|
return {
|
|
85
85
|
userId: login['record']['id'],
|
|
86
|
-
request: (path, body) =>
|
|
86
|
+
request: (path, body, method) =>
|
|
87
|
+
call(url, path, token, {
|
|
88
|
+
...(body ? { body } : {}),
|
|
89
|
+
...(method ? { method } : {}),
|
|
90
|
+
}),
|
|
87
91
|
};
|
|
88
92
|
}
|
|
89
93
|
async function call(
|
|
90
94
|
url: string,
|
|
91
95
|
path: string,
|
|
92
96
|
token: string,
|
|
93
|
-
body?: object
|
|
97
|
+
{ body, method }: { body?: object; method?: 'PATCH' | 'DELETE' } = {}
|
|
94
98
|
): Promise<unknown> {
|
|
95
99
|
if (!path.startsWith('/api/')) throw new Error('BESZEL_API_PATH');
|
|
96
100
|
const response = await fetch(`${url}${path}`, {
|
|
97
|
-
method: body ? 'POST' : 'GET',
|
|
101
|
+
method: method ?? (body ? 'POST' : 'GET'),
|
|
98
102
|
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
|
99
103
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
100
104
|
redirect: 'error',
|
|
101
105
|
signal: AbortSignal.timeout(10_000),
|
|
102
106
|
});
|
|
103
107
|
if (!response.ok) throw new Error(failure(response.status, path));
|
|
108
|
+
// PocketBase answers a deletion with no content.
|
|
109
|
+
if (response.status === 204) return null;
|
|
104
110
|
return response.json() as Promise<unknown>;
|
|
105
111
|
}
|
|
106
112
|
|