@crvouga/dev-fleet 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -9
- package/agents/audit.md +18 -18
- package/ansible/agents/agents.yml +134 -0
- package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
- package/ansible/agents/templates/claude.settings.json.j2 +3 -0
- package/ansible/agents/templates/codex.config.toml.j2 +11 -0
- package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
- package/ansible/agents/templates/grok.config.toml.j2 +19 -0
- package/ansible/agents/templates/opencode.json.j2 +24 -0
- package/ansible/agents/templates/receipt.json.j2 +10 -0
- package/ansible/ansible.cfg +14 -0
- package/ansible/github/git.yml +82 -0
- package/ansible/macos/manager-privilege.yml +55 -9
- package/ansible/macos/manager.yml +154 -7
- package/ansible/macos/policy.json +13 -0
- package/ansible/macos/t3-policy.yml +5 -3
- package/ansible/macos/vault-renew.yml +85 -0
- package/ansible/monitoring/agent-darwin.yml +3 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/monitoring/hub-darwin.yml +3 -0
- package/ansible/monitoring/hub-route-darwin.yml +1 -0
- package/ansible/net/remote-probe.sh +4 -1
- package/ansible/platforms.json +2 -2
- package/ansible/pyproject.toml +5 -0
- package/ansible/ubuntu/baseline.yml +39 -13
- package/ansible/ubuntu/disk-gc.py +6 -2
- package/ansible/ubuntu/firewall.nft.j2 +50 -0
- package/ansible/ubuntu/policy.json +125 -6
- package/ansible/ubuntu/reboot.yml +89 -0
- package/ansible/ubuntu/recovery.yml +13 -3
- package/ansible/ubuntu/t3/policy.json +3 -0
- package/ansible/ubuntu/t3-desktop.yml +23 -2
- package/ansible/ubuntu/t3-headless.yml +128 -0
- package/ansible/ubuntu/t3-health.sh +24 -10
- package/ansible/ubuntu/t3-idle.py +37 -2
- package/ansible/ubuntu/t3-policy.yml +4 -1
- package/ansible/ubuntu/t3-route.yml +1 -0
- package/ansible/ubuntu/tailscale.yml +54 -8
- package/ansible/ubuntu/tasks/browser.yml +3 -0
- package/ansible/ubuntu/tasks/containers-off.yml +43 -0
- package/ansible/ubuntu/tasks/docker.yml +53 -6
- package/ansible/ubuntu/tasks/dpkg.yml +4 -0
- package/ansible/ubuntu/tasks/features.yml +22 -0
- package/ansible/ubuntu/tasks/firewall.yml +172 -0
- package/ansible/ubuntu/tasks/install.yml +2 -1
- package/ansible/ubuntu/tasks/packages.yml +4 -2
- package/ansible/ubuntu/tasks/release.yml +6 -0
- package/ansible/ubuntu/tasks/resources.yml +6 -4
- package/ansible/ubuntu/tasks/services.yml +2 -2
- package/ansible/ubuntu/tasks/sizing.yml +49 -0
- package/ansible/ubuntu/tasks/snapshot.yml +14 -3
- package/ansible/ubuntu/tasks/ssh.yml +61 -4
- package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
- package/ansible/ubuntu/tasks/user.yml +1 -0
- package/ansible/ubuntu/worker-account.yml +117 -0
- package/ansible/uv.lock +265 -0
- package/bin/devfleet.mjs +42 -4
- package/bootstrap/fleet-remote-bootstrap.sh +91 -25
- package/bootstrap/global-config.ts +29 -3
- package/bootstrap/package-smoke.ts +127 -0
- package/bootstrap/pins.ts +189 -0
- package/bootstrap/release-notes.ts +88 -0
- package/bootstrap/release-version.ts +41 -7
- package/bootstrap/worker-features.ts +114 -0
- package/bootstrap/worker-seed.yaml +7 -3
- package/bootstrap/worker-tools.ts +39 -9
- package/cliproxy/README.md +1 -1
- package/cliproxy/docs/health-workflow.example.yml +20 -143
- package/docs/authentication.md +19 -11
- package/docs/consumer-setup.md +42 -27
- package/docs/convergence.md +67 -0
- package/docs/declared.md +61 -34
- package/docs/machine-setup.md +73 -7
- package/docs/manager-setup.md +10 -3
- package/docs/operations.md +64 -9
- package/docs/releasing.md +49 -1
- package/docs/runbooks.md +98 -7
- package/docs/state.md +23 -11
- package/docs/support-matrix.md +40 -4
- package/docs/topology.md +67 -6
- package/docs/ubuntu-sync.md +78 -28
- package/docs/worker-contract.md +122 -19
- package/docs/worker-requirements.md +107 -0
- package/examples/fleet.json +1 -0
- package/examples/workers-from-infrastructure/main.tf +132 -0
- package/examples/workers-from-infrastructure/worker-records.json +14 -0
- package/llms.txt +3 -1
- package/mise.lock +29 -0
- package/mise.toml +5 -3
- package/package.json +2 -2
- package/schema/check-ids.json +1967 -170
- package/schema/check-ids.schema.json +34 -0
- package/schema/fleet.schema.json +327 -8
- package/schema/output/check.schema.json +1 -1
- package/schema/output/fleet-state-definition.schema.json +14 -0
- package/schema/output/fleet-sync-schedule.schema.json +26 -0
- package/schema/output/fleet-sync.schema.json +18 -0
- package/schema/output/report.schema.json +3 -2
- package/schema/worker-record.schema.json +76 -0
- package/src/agents/converge.ts +140 -24
- package/src/agents/files.ts +2 -32
- package/src/agents/native-verify.ts +34 -3
- package/src/agents/secrets.ts +39 -4
- package/src/check-owners.ts +61 -2
- package/src/check-registry.ts +630 -0
- package/src/cli/agent-guide.ts +3 -3
- package/src/cli/cli.ts +4 -0
- package/src/cli/fleet-manifest.ts +9 -3
- package/src/cli/fleet-node-lifecycle.ts +313 -64
- package/src/cli/fleet-node-park.ts +238 -0
- package/src/cli/fleet-node-seed.ts +51 -35
- package/src/cli/fleet-state.ts +2 -1
- package/src/cli/fleet-sync-checkout.ts +7 -0
- package/src/cli/fleet-sync-enrollment.ts +193 -32
- package/src/cli/fleet-sync-forward.ts +4 -0
- package/src/cli/fleet-sync-manager.ts +103 -33
- package/src/cli/fleet-sync-output.ts +31 -11
- package/src/cli/fleet-sync-plan.ts +65 -9
- package/src/cli/fleet-sync-records.ts +113 -0
- package/src/cli/fleet-sync-remote.ts +151 -9
- package/src/cli/fleet-sync-schedule.ts +14 -4
- package/src/cli/fleet-sync-state.ts +28 -16
- package/src/cli/fleet-sync-vault.ts +77 -0
- package/src/cli/fleet-sync-worker.ts +214 -0
- package/src/cli/fleet-sync.ts +150 -19
- package/src/cli/fleet-validate.ts +59 -21
- package/src/cli/fleet-watch.ts +7 -1
- package/src/cli/fleet.ts +87 -5
- package/src/cli/managed-health.ts +25 -1
- package/src/cli/register-fleet.ts +115 -4
- package/src/cli/registry.ts +14 -0
- package/src/cli/tailnet.ts +43 -10
- package/src/cli/ui-data.ts +3 -1
- package/src/cli/worker-usage-report.ts +63 -0
- package/src/clock.ts +42 -0
- package/src/definition.ts +332 -9
- package/src/diagnostics.ts +5 -2
- package/src/exposure.ts +102 -0
- package/src/fleet-config.ts +3 -1
- package/src/git-identity.ts +76 -3
- package/src/ide/ide.ts +5 -0
- package/src/ide/t3.ts +12 -2
- package/src/input-hashes.ts +6 -0
- package/src/machine/machine-class.ts +59 -0
- package/src/machine/platform.ts +90 -11
- package/src/machine/state.ts +37 -15
- package/src/machine/support-matrix.ts +11 -9
- package/src/machine-health.ts +28 -5
- package/src/manager-health.ts +148 -2
- package/src/manager-sync.ts +65 -4
- package/src/monitoring/beszel.ts +8 -8
- package/src/monitoring/enroll.ts +8 -1
- package/src/monitoring/hub-access.ts +10 -4
- package/src/monitoring/systems.ts +138 -0
- package/src/network-definition.ts +13 -4
- package/src/node-converge-linux.ts +136 -27
- package/src/node-converge.ts +13 -3
- package/src/platform/ansible-receipt.ts +17 -2
- package/src/platform/ansible.ts +40 -4
- package/src/platform/manager-host.ts +25 -8
- package/src/platform/resource-verify.ts +18 -7
- package/src/platform/t3-policy.ts +24 -10
- package/src/platform/t3-service.ts +109 -34
- package/src/platform/ubuntu-release.ts +24 -0
- package/src/platform/ubuntu-verify.ts +31 -3
- package/src/platform/ubuntu-worker.ts +58 -0
- package/src/platform/worker-platform.ts +72 -0
- package/src/protocol.ts +1 -1
- package/src/public-reachability.ts +186 -0
- package/src/remote-bootstrap.ts +50 -12
- package/src/remote-operation.ts +18 -4
- package/src/repair-plan.ts +23 -12
- package/src/report.ts +2 -1
- package/src/schedule-check.ts +21 -2
- package/src/secrets.ts +4 -0
- package/src/service-auth.ts +55 -48
- package/src/ssh-connection.ts +141 -0
- package/src/ssh.ts +63 -24
- package/src/state/store.ts +4 -2
- package/src/state/sync.ts +35 -6
- package/src/state/workers.ts +140 -0
- package/src/t3/pairing.ts +59 -0
- package/src/t3/peer-link.ts +333 -0
- package/src/t3/version.ts +1 -1
- package/src/tailnet-cleanup.ts +185 -0
- package/src/tailnet-control.ts +346 -0
- package/src/tailscale.ts +1 -1
- package/src/toolchain-checks.ts +11 -1
- package/src/types.ts +4 -2
- package/src/worker-account.ts +63 -0
- package/src/worker-activity.ts +303 -0
- package/src/worker-capacity.ts +31 -0
- package/src/worker-exposure.ts +388 -0
- package/src/worker-features.ts +201 -0
- package/src/worker-host-checks.ts +71 -20
- package/src/worker-manifest.ts +27 -4
- package/src/worker-parking.ts +70 -0
- package/src/worker-policy.ts +17 -1
- package/src/worker-readiness.ts +15 -3
- package/src/worker-reboot.ts +129 -0
- package/src/worker-records.ts +305 -0
- package/src/worker-seed.ts +16 -11
- package/src/worker-sizing.ts +76 -0
- package/src/cli/schedule-logs.ts +0 -36
package/src/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. */
|
|
@@ -39,6 +44,10 @@ export interface ManagerReport {
|
|
|
39
44
|
desiredRevision: string | null;
|
|
40
45
|
/** The worker that proved a revision new to the fleet first, when one did. */
|
|
41
46
|
canary?: string;
|
|
47
|
+
/** The workers this run enrolled (added to the roster, or bound to their
|
|
48
|
+
* tailnet device).
|
|
49
|
+
*/
|
|
50
|
+
enrolled?: string[];
|
|
42
51
|
checks: Check[];
|
|
43
52
|
nodes: ManagerNode[];
|
|
44
53
|
candidates: number;
|
|
@@ -49,17 +58,30 @@ export type DeployNode = (
|
|
|
49
58
|
revision: string
|
|
50
59
|
) => Promise<NodeReport>;
|
|
51
60
|
|
|
52
|
-
/** 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
|
+
*/
|
|
53
65
|
export async function inspectBootstrap(
|
|
54
66
|
node: FleetNode,
|
|
55
|
-
run: Runner
|
|
67
|
+
run: Runner,
|
|
68
|
+
clock?: () => number
|
|
56
69
|
): Promise<Check[]> {
|
|
70
|
+
const sentAt = clock?.();
|
|
57
71
|
const result = await run([...sshArgs(node), PLATFORM_PROBE], {
|
|
58
72
|
timeoutMs: 20_000,
|
|
59
73
|
});
|
|
74
|
+
const receivedAt = clock?.();
|
|
60
75
|
if (!result.ok) return [sshFailure(result)];
|
|
61
76
|
const baseline = evaluatePlatform(result.stdout);
|
|
62
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);
|
|
63
85
|
return [
|
|
64
86
|
{
|
|
65
87
|
id: 'ssh:fresh-session',
|
|
@@ -85,6 +107,25 @@ export async function inspectBootstrap(
|
|
|
85
107
|
'Run the worker on a physical or virtual machine of its own; docs/support-matrix.md lists what is supported.',
|
|
86
108
|
},
|
|
87
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,
|
|
88
129
|
];
|
|
89
130
|
}
|
|
90
131
|
|
|
@@ -98,11 +139,17 @@ export async function synchronizeManager(
|
|
|
98
139
|
revision: string;
|
|
99
140
|
apply: boolean;
|
|
100
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;
|
|
101
146
|
progress?: (node: string, phase: string) => void;
|
|
102
147
|
/** Called once per node as soon as its result is final. */
|
|
103
148
|
finished?: (node: ManagerNode) => void;
|
|
104
149
|
/** Converges the manager locally; omitted in tests. */
|
|
105
150
|
manager?: () => Promise<ManagerNode>;
|
|
151
|
+
/** The manager's clock, in milliseconds; workers are measured against it. */
|
|
152
|
+
clock?: () => number;
|
|
106
153
|
},
|
|
107
154
|
run: Runner,
|
|
108
155
|
deploy: DeployNode
|
|
@@ -146,6 +193,7 @@ export async function synchronizeManager(
|
|
|
146
193
|
revision: input.revision,
|
|
147
194
|
computers: tailnet.computers,
|
|
148
195
|
matches,
|
|
196
|
+
clock: input.clock ?? Date.now,
|
|
149
197
|
},
|
|
150
198
|
run
|
|
151
199
|
);
|
|
@@ -192,6 +240,7 @@ async function applyNodes(
|
|
|
192
240
|
nodes: FleetNode[];
|
|
193
241
|
revision: string;
|
|
194
242
|
canary?: string;
|
|
243
|
+
serial?: boolean;
|
|
195
244
|
progress?: (node: string, phase: string) => void;
|
|
196
245
|
finished?: (node: ManagerNode) => void;
|
|
197
246
|
},
|
|
@@ -216,7 +265,16 @@ 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
|
+
}
|
|
220
278
|
}
|
|
221
279
|
|
|
222
280
|
/** A revision new to the fleet is proven on one worker first: the preferred
|
|
@@ -322,6 +380,7 @@ async function inspectManagerNode(
|
|
|
322
380
|
revision: string;
|
|
323
381
|
computers: TailnetComputer[];
|
|
324
382
|
matches: Map<string, FleetNode[]>;
|
|
383
|
+
clock: () => number;
|
|
325
384
|
},
|
|
326
385
|
run: Runner
|
|
327
386
|
): Promise<ManagerNode> {
|
|
@@ -357,7 +416,9 @@ async function inspectManagerNode(
|
|
|
357
416
|
},
|
|
358
417
|
});
|
|
359
418
|
else {
|
|
360
|
-
|
|
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)));
|
|
361
422
|
checks.push(...identityChecks(node, peer));
|
|
362
423
|
if (peer) checks.push(keyExpiryCheck(peer));
|
|
363
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
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { record } from '../config';
|
|
2
|
+
import type { Check } from '../types';
|
|
3
|
+
import type { HubRequest } from './enroll';
|
|
4
|
+
|
|
5
|
+
/** The hub keeps one system per worker, named by its fleet ID. These change
|
|
6
|
+
* that list when a worker is replaced or retired, and find the systems no
|
|
7
|
+
* declared worker matches. Only names and hosts are read; a record's other
|
|
8
|
+
* fields (tokens, users) are never returned.
|
|
9
|
+
*/
|
|
10
|
+
export interface HubSystem {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
host: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function systems(value: unknown): HubSystem[] {
|
|
17
|
+
if (!record(value) || !Array.isArray(value['items']))
|
|
18
|
+
throw new Error('BESZEL_PROTOCOL');
|
|
19
|
+
return value['items'].flatMap((item) =>
|
|
20
|
+
record(item) &&
|
|
21
|
+
typeof item['id'] === 'string' &&
|
|
22
|
+
/^[a-z0-9]{15}$/.test(item['id']) &&
|
|
23
|
+
typeof item['name'] === 'string'
|
|
24
|
+
? [
|
|
25
|
+
{
|
|
26
|
+
id: item['id'],
|
|
27
|
+
name: item['name'],
|
|
28
|
+
host: typeof item['host'] === 'string' ? item['host'] : '',
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
: []
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function listSystems(request: HubRequest): Promise<HubSystem[]> {
|
|
36
|
+
return systems(
|
|
37
|
+
await request(
|
|
38
|
+
'/api/collections/systems/records?perPage=500&fields=id,name,host'
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function named(request: HubRequest, node: string): Promise<HubSystem[]> {
|
|
44
|
+
return systems(
|
|
45
|
+
await request(
|
|
46
|
+
`/api/collections/systems/records?filter=${encodeURIComponent(`name="${node}"`)}&perPage=2&fields=id,name,host`
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const check = (status: Check['status'], summary: string, action?: string) => ({
|
|
52
|
+
id: 'beszel:system',
|
|
53
|
+
status,
|
|
54
|
+
summary,
|
|
55
|
+
...(action ? { remedy: { action } } : {}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Points a replaced worker's system at the host it is reached by now, so
|
|
59
|
+
* the hub keeps one system for the ID and its history.
|
|
60
|
+
*/
|
|
61
|
+
export async function moveSystem(
|
|
62
|
+
request: HubRequest,
|
|
63
|
+
node: string,
|
|
64
|
+
host: string
|
|
65
|
+
): Promise<Check> {
|
|
66
|
+
const found = await named(request, node);
|
|
67
|
+
if (found.length > 1)
|
|
68
|
+
return check(
|
|
69
|
+
'fail',
|
|
70
|
+
'BESZEL_DUPLICATE_NODE',
|
|
71
|
+
`The hub holds more than one system named ${node}. Delete the extra ones in the dashboard, then run the command again.`
|
|
72
|
+
);
|
|
73
|
+
if (!found[0])
|
|
74
|
+
return check(
|
|
75
|
+
'pass',
|
|
76
|
+
`The hub holds no system for ${node}; fleet-sync enrolls the new machine.`
|
|
77
|
+
);
|
|
78
|
+
if (found[0].host === host)
|
|
79
|
+
return check('pass', `The hub's system ${node} already watches ${host}.`);
|
|
80
|
+
await request(
|
|
81
|
+
`/api/collections/systems/records/${found[0].id}`,
|
|
82
|
+
{ host },
|
|
83
|
+
'PATCH'
|
|
84
|
+
);
|
|
85
|
+
return check('pass', `The hub's system ${node} now watches ${host}.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Deletes a retired worker's system and its agent fingerprint. */
|
|
89
|
+
export async function removeSystem(
|
|
90
|
+
request: HubRequest,
|
|
91
|
+
node: string
|
|
92
|
+
): Promise<Check> {
|
|
93
|
+
const found = await named(request, node);
|
|
94
|
+
for (const system of found) {
|
|
95
|
+
const prints = await request(
|
|
96
|
+
`/api/collections/fingerprints/records?filter=${encodeURIComponent(`system="${system.id}"`)}&perPage=10&fields=id`
|
|
97
|
+
);
|
|
98
|
+
for (const print of record(prints) && Array.isArray(prints['items'])
|
|
99
|
+
? prints['items']
|
|
100
|
+
: [])
|
|
101
|
+
if (record(print) && typeof print['id'] === 'string')
|
|
102
|
+
await request(
|
|
103
|
+
`/api/collections/fingerprints/records/${print['id']}`,
|
|
104
|
+
undefined,
|
|
105
|
+
'DELETE'
|
|
106
|
+
);
|
|
107
|
+
await request(
|
|
108
|
+
`/api/collections/systems/records/${system.id}`,
|
|
109
|
+
undefined,
|
|
110
|
+
'DELETE'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return check(
|
|
114
|
+
'pass',
|
|
115
|
+
found.length
|
|
116
|
+
? `The hub no longer watches ${node}.`
|
|
117
|
+
: `The hub holds no system for ${node}.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A system no declared worker (and not the manager) is named after: what a
|
|
122
|
+
* worker removed by hand, or destroyed outside dev-fleet, leaves behind.
|
|
123
|
+
*/
|
|
124
|
+
export function leftoverSystems(
|
|
125
|
+
found: HubSystem[],
|
|
126
|
+
declared: string[]
|
|
127
|
+
): Check[] {
|
|
128
|
+
return found
|
|
129
|
+
.filter((system) => !declared.includes(system.name))
|
|
130
|
+
.map((system) => ({
|
|
131
|
+
id: 'beszel:leftover',
|
|
132
|
+
status: 'warn' as const,
|
|
133
|
+
summary: `The hub watches ${system.name}, which is no declared worker.`,
|
|
134
|
+
remedy: {
|
|
135
|
+
action: `If ${system.name} left the fleet, retire it (devfleet fleet-node-retire --id ${system.name} --apply --yes while it is still declared) or delete the system in the dashboard.`,
|
|
136
|
+
},
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
sshTarget,
|
|
3
|
+
type FleetDefinition,
|
|
4
|
+
type MachineDefinition,
|
|
5
|
+
} from './definition';
|
|
2
6
|
import { record } from './config';
|
|
3
7
|
import { FleetUsageError } from './errors';
|
|
4
8
|
|
|
@@ -19,8 +23,11 @@ export function networkProjection(
|
|
|
19
23
|
node.network
|
|
20
24
|
? [
|
|
21
25
|
{
|
|
22
|
-
alias: node
|
|
23
|
-
|
|
26
|
+
alias: sshTarget(node),
|
|
27
|
+
tailscaleName: node.network.tailscaleName,
|
|
28
|
+
user: node.network.user,
|
|
29
|
+
sshPort: node.network.sshPort,
|
|
30
|
+
t3Port: node.network.t3Port,
|
|
24
31
|
os: node.os,
|
|
25
32
|
identityFile: identities[node.id] ?? '',
|
|
26
33
|
},
|
|
@@ -34,7 +41,9 @@ function compatible(
|
|
|
34
41
|
): node is MachineDefinition {
|
|
35
42
|
return Boolean(
|
|
36
43
|
node?.network &&
|
|
37
|
-
Object.entries(node.network)
|
|
44
|
+
Object.entries(node.network)
|
|
45
|
+
.filter(([key]) => key !== 'hostKey')
|
|
46
|
+
.every(([key, value]) => host[key] === value) &&
|
|
38
47
|
node.os === host['os']
|
|
39
48
|
);
|
|
40
49
|
}
|