@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,21 +1,58 @@
|
|
|
1
1
|
import { mkdir, rm } from 'node:fs/promises';
|
|
2
2
|
import { loadRoster } from './roster';
|
|
3
|
-
import { enrolledRoster } from '../definition';
|
|
3
|
+
import { enrolledRoster, sshTarget, type FleetDefinition } from '../definition';
|
|
4
4
|
import { FleetUsageError } from '../errors';
|
|
5
5
|
import { dirname } from 'node:path';
|
|
6
|
-
import { fleet
|
|
6
|
+
import { fleet } from '../fleet-config';
|
|
7
7
|
import { discoverTailnet } from '../tailscale';
|
|
8
8
|
import { matchComputers } from '../tailnet-matching';
|
|
9
9
|
import { sshArgs, sshFailure } from '../ssh';
|
|
10
10
|
import { ensurePrivateRoot } from '../machine/state';
|
|
11
|
-
import
|
|
11
|
+
import { createWorkerAccount } from '../worker-account';
|
|
12
|
+
import { packagePlaybook, type PackagePlaybook } from '../platform/ansible';
|
|
13
|
+
import type { Check, FleetNode, Runner } from '../types';
|
|
12
14
|
import { STATE_ROSTER, readRosterText, writeRosterText } from '../state/roster';
|
|
15
|
+
import { readParked } from '../state/workers';
|
|
13
16
|
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
17
|
+
/** What an enrollment did: the workers whose roster entry it added or
|
|
18
|
+
* completed, and the checks that say why another one was not enrolled (or
|
|
19
|
+
* what was done to reach it).
|
|
16
20
|
*/
|
|
17
|
-
export
|
|
18
|
-
|
|
21
|
+
export interface EnrollmentResult {
|
|
22
|
+
enrolled: string[];
|
|
23
|
+
checks: { node: string; check: Check }[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EnrollmentOptions {
|
|
27
|
+
/** A person is at the terminal and sees the list of what is enrolled. */
|
|
28
|
+
interactive: boolean;
|
|
29
|
+
/** A worker not on the tailnet yet joins it during this run: the fleet
|
|
30
|
+
* declares a tailnet control plane that issues single-use keys.
|
|
31
|
+
*/
|
|
32
|
+
joinable?: boolean;
|
|
33
|
+
definition?: FleetDefinition;
|
|
34
|
+
/** Applies a playbook of the package from the manager. */
|
|
35
|
+
playbook?: PackagePlaybook;
|
|
36
|
+
/** When each parked worker was parked; default the fleet state's. */
|
|
37
|
+
parked?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Enrolls declared workers, and resumes earlier enrollments. Existing paths
|
|
41
|
+
* and explicit identities are preserved. Tailnet candidates never enter the
|
|
42
|
+
* declared membership. An interactive run enrolls what a fresh
|
|
43
|
+
* host-key-verified SSH session reaches. A run without a person enrolls a
|
|
44
|
+
* worker only when every fact is verified: its pinned host key matched (the
|
|
45
|
+
* session is strict), `sudo -n` works, and exactly one tailnet device matches
|
|
46
|
+
* it, or none when this run joins it to the tailnet itself. Anything
|
|
47
|
+
* ambiguous stays out, with the code that says why; nothing prompts.
|
|
48
|
+
*/
|
|
49
|
+
export async function enrollManager(
|
|
50
|
+
path: string,
|
|
51
|
+
run: Runner,
|
|
52
|
+
options: EnrollmentOptions = { interactive: true }
|
|
53
|
+
): Promise<EnrollmentResult> {
|
|
54
|
+
const canonical = options.definition ?? fleet;
|
|
55
|
+
const result: EnrollmentResult = { enrolled: [], checks: [] };
|
|
19
56
|
const original = await existingEnrollment(path);
|
|
20
57
|
const saved =
|
|
21
58
|
original === null
|
|
@@ -23,54 +60,178 @@ export async function enrollManager(path: string, run: Runner): Promise<void> {
|
|
|
23
60
|
: enrolledRoster(canonical, await loadRoster(path)).nodes;
|
|
24
61
|
const nodes: FleetNode[] = canonical.workers.map((node) => ({
|
|
25
62
|
id: node.id,
|
|
26
|
-
ssh: node
|
|
63
|
+
ssh: sshTarget(node),
|
|
27
64
|
workspace: '/',
|
|
28
65
|
required: ['t3', 'toolchain', 'workstation'],
|
|
29
66
|
}));
|
|
67
|
+
// A parked worker is off on purpose: it is enrolled once it is resumed.
|
|
68
|
+
const parked = options.parked ?? readParked();
|
|
30
69
|
const pending = nodes.filter(
|
|
31
|
-
(node) =>
|
|
70
|
+
(node) =>
|
|
71
|
+
!parked[node.id] &&
|
|
72
|
+
!saved.find((entry) => entry.id === node.id)?.tailscaleId
|
|
32
73
|
);
|
|
33
|
-
if (!pending.length) return;
|
|
74
|
+
if (!pending.length) return result;
|
|
34
75
|
const tailnet = await discoverTailnet(run);
|
|
35
76
|
const matches = await matchComputers(nodes, tailnet.computers, run);
|
|
36
77
|
const enrolled: FleetNode[] = [];
|
|
37
78
|
for (const node of pending) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
79
|
+
// A worker already in the roster reports its own state in the run.
|
|
80
|
+
const known = saved.some((entry) => entry.id === node.id);
|
|
81
|
+
const connection = await enrollOne(node, {
|
|
82
|
+
run,
|
|
83
|
+
options,
|
|
84
|
+
peers: tailnet.computers
|
|
85
|
+
.filter((peer) =>
|
|
86
|
+
matches.get(peer.id)?.some((candidate) => candidate.id === node.id)
|
|
87
|
+
)
|
|
88
|
+
.map((peer) => peer.id),
|
|
89
|
+
matches,
|
|
90
|
+
user: canonical.workers.find((worker) => worker.id === node.id)?.network
|
|
91
|
+
?.user,
|
|
92
|
+
note: (check) => {
|
|
93
|
+
if (!known) result.checks.push({ node: node.id, check });
|
|
94
|
+
},
|
|
95
|
+
});
|
|
49
96
|
if (connection) enrolled.push(connection);
|
|
50
97
|
}
|
|
51
98
|
const merged = mergeEnrollment(saved, enrolled);
|
|
52
|
-
if (JSON.stringify(merged) === JSON.stringify(saved)) return;
|
|
99
|
+
if (JSON.stringify(merged) === JSON.stringify(saved)) return result;
|
|
53
100
|
// Only canonical nodes reached over host-key-verified SSH get here, so
|
|
54
101
|
// enrollment needs no extra confirmation; the list is still shown.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
102
|
+
if (options.interactive)
|
|
103
|
+
process.stderr.write(
|
|
104
|
+
`Enrolling verified SSH connections for canonical targets:\n${enrolled.map((node) => ` ${node.id} → ${node.ssh}; device ${enrollmentIdentity(node)}`).join('\n')}\nMissing targets remain visible and are never replaced by discovered peers.\n`
|
|
105
|
+
);
|
|
58
106
|
await persistEnrollment(path, original, merged);
|
|
107
|
+
result.enrolled = merged
|
|
108
|
+
.filter(
|
|
109
|
+
(node) =>
|
|
110
|
+
JSON.stringify(node) !==
|
|
111
|
+
JSON.stringify(saved.find((entry) => entry.id === node.id))
|
|
112
|
+
)
|
|
113
|
+
.map((node) => node.id);
|
|
114
|
+
return result;
|
|
59
115
|
}
|
|
60
116
|
|
|
117
|
+
/** One worker's connection, when every fact this run needs is verified. */
|
|
118
|
+
async function enrollOne(
|
|
119
|
+
node: FleetNode,
|
|
120
|
+
context: {
|
|
121
|
+
run: Runner;
|
|
122
|
+
options: EnrollmentOptions;
|
|
123
|
+
/** The tailnet devices that match this worker. */
|
|
124
|
+
peers: string[];
|
|
125
|
+
matches: Map<string, FleetNode[]>;
|
|
126
|
+
user: string | undefined;
|
|
127
|
+
note: (check: Check) => void;
|
|
128
|
+
}
|
|
129
|
+
): Promise<FleetNode | null> {
|
|
130
|
+
const { run, options, peers, note } = context;
|
|
131
|
+
if (ambiguous(peers, context.matches)) {
|
|
132
|
+
if (!options.interactive) note(IDENTITY_CONFLICT);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
if (!options.interactive && !peers.length && !options.joinable) {
|
|
136
|
+
note(NOT_ON_TAILNET);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const connection = await inspectConnection(node, peers[0], {
|
|
140
|
+
run,
|
|
141
|
+
options,
|
|
142
|
+
user: context.user,
|
|
143
|
+
note,
|
|
144
|
+
quiet: !options.interactive,
|
|
145
|
+
});
|
|
146
|
+
if (!connection || options.interactive) return connection;
|
|
147
|
+
const privilege = await passwordlessSudo(connection, run);
|
|
148
|
+
if (privilege) note(privilege);
|
|
149
|
+
return privilege ? null : connection;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const IDENTITY_CONFLICT: Check = {
|
|
153
|
+
id: 'tailscale:identity',
|
|
154
|
+
status: 'fail',
|
|
155
|
+
summary: 'TAILSCALE_IDENTITY_CONFLICT',
|
|
156
|
+
remedy: {
|
|
157
|
+
action:
|
|
158
|
+
'More than one tailnet device matches this worker, so it was not enrolled. Remove the stale device from the tailnet, then rerun fleet-sync.',
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const NOT_ON_TAILNET: Check = {
|
|
163
|
+
id: 'tailscale:enrolled-identity',
|
|
164
|
+
status: 'fail',
|
|
165
|
+
summary: 'TAILSCALE_ENROLLMENT_REQUIRED',
|
|
166
|
+
remedy: {
|
|
167
|
+
action:
|
|
168
|
+
'No tailnet device matches this worker and fleet.json declares no tailnet control plane to join it with, so a run without a person did not enroll it. Declare tailnet with the secret tailnet-admin, join the machine yourself, or run fleet-sync once in a terminal.',
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** A run without a person never prompts for sudo: the account has it, or the
|
|
173
|
+
* worker is not enrolled.
|
|
174
|
+
*/
|
|
175
|
+
async function passwordlessSudo(
|
|
176
|
+
node: FleetNode,
|
|
177
|
+
run: Runner
|
|
178
|
+
): Promise<Check | null> {
|
|
179
|
+
const result = await run([...sshArgs(node), 'sudo -n true'], {
|
|
180
|
+
timeoutMs: 15_000,
|
|
181
|
+
});
|
|
182
|
+
if (result.ok) return null;
|
|
183
|
+
if (result.reason !== 'exit' || !/sudo:/i.test(result.stderr))
|
|
184
|
+
return sshFailure(result);
|
|
185
|
+
return {
|
|
186
|
+
id: 'bootstrap:privilege',
|
|
187
|
+
status: 'fail',
|
|
188
|
+
summary: 'SUDO_AUTHORIZATION_REQUIRED',
|
|
189
|
+
remedy: {
|
|
190
|
+
action:
|
|
191
|
+
'The worker account has no passwordless sudo, so a run without a person did not enroll it. Grant it (a fleet-node-seed seed does), or run fleet-sync once in a terminal to authorize it.',
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The account's home over a fresh strict session. A machine whose image
|
|
197
|
+
* lets only root in refuses the declared account: it is created once over
|
|
198
|
+
* the root login, and the session is opened again.
|
|
199
|
+
*/
|
|
61
200
|
async function inspectConnection(
|
|
62
201
|
node: FleetNode,
|
|
63
202
|
peerId: string | undefined,
|
|
64
|
-
|
|
203
|
+
context: {
|
|
204
|
+
run: Runner;
|
|
205
|
+
options: EnrollmentOptions;
|
|
206
|
+
user: string | undefined;
|
|
207
|
+
note: (check: Check) => void;
|
|
208
|
+
quiet: boolean;
|
|
209
|
+
}
|
|
65
210
|
): Promise<FleetNode | null> {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
211
|
+
const { run } = context;
|
|
212
|
+
const probe = () =>
|
|
213
|
+
run([...sshArgs(node), 'printf "%s" "$HOME"'], { timeoutMs: 20_000 });
|
|
214
|
+
let home = await probe();
|
|
215
|
+
if (!home.ok && sshFailure(home).id === 'ssh:auth' && context.user) {
|
|
216
|
+
const account = await createWorkerAccount(
|
|
217
|
+
{ id: node.id, ssh: node.ssh, login: 'root' },
|
|
218
|
+
context.user,
|
|
219
|
+
context.options.playbook ?? packagePlaybook(run)
|
|
220
|
+
);
|
|
221
|
+
// Only an account that was created is reported here: a root login that
|
|
222
|
+
// is refused as well stays the SSH failure below.
|
|
223
|
+
if (account.status === 'pass') {
|
|
224
|
+
context.note(account);
|
|
225
|
+
home = await probe();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
69
228
|
if (!home.ok) {
|
|
70
229
|
const check = sshFailure(home);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
230
|
+
if (context.quiet) context.note(check);
|
|
231
|
+
else
|
|
232
|
+
process.stderr.write(
|
|
233
|
+
`[${node.id}] ${check.id}: ${check.summary} ${check.remedy?.action ?? ''}\n`
|
|
234
|
+
);
|
|
74
235
|
return null;
|
|
75
236
|
}
|
|
76
237
|
if (!/^\/[^\x00-\x1f\x7f]{1,4000}$/.test(home.stdout)) return null;
|
|
@@ -104,14 +104,20 @@ export function forwardedFlags(
|
|
|
104
104
|
): string[] {
|
|
105
105
|
const flags = revision ? ['--revision', revision] : [];
|
|
106
106
|
for (const node of options.node ?? []) flags.push('--node', node);
|
|
107
|
+
if (options.canary) flags.push('--canary', options.canary);
|
|
107
108
|
if (options.schedule) flags.push('--schedule', options.schedule);
|
|
108
109
|
const switches: [boolean | undefined, string][] = [
|
|
109
110
|
[options.all, '--all'],
|
|
111
|
+
[options.vaultPasswordLogin, '--vault-password-login'],
|
|
112
|
+
[options.restartBusy, '--restart-busy'],
|
|
110
113
|
[options.dryRun, '--dry-run'],
|
|
111
114
|
[options.apply, '--apply'],
|
|
112
115
|
[options.yes, '--yes'],
|
|
113
116
|
[options.json, '--json'],
|
|
114
117
|
[options.nonInteractive, '--non-interactive'],
|
|
118
|
+
[options.maintenanceReboot, '--maintenance-reboot'],
|
|
119
|
+
[options.force, '--force'],
|
|
120
|
+
[options.takeOver, '--take-over'],
|
|
115
121
|
];
|
|
116
122
|
for (const [on, flag] of switches) if (on) flags.push(flag);
|
|
117
123
|
return flags;
|
|
@@ -14,11 +14,15 @@ import { enabled, managerChecks } from '../ide/orchestrate';
|
|
|
14
14
|
import { idesOf } from '../ide/definition';
|
|
15
15
|
import { convergeGitIdentity, gitChecks } from '../git-identity';
|
|
16
16
|
import { ansibleCommand } from '../platform/ansible';
|
|
17
|
+
import { runAnsibleWithReceipt } from '../platform/ansible-receipt';
|
|
18
|
+
import macos from '../../ansible/macos/policy.json';
|
|
19
|
+
import { scheduleIntervals } from '../schedule-check';
|
|
17
20
|
import { convergeGithub } from '../github/converge';
|
|
18
21
|
import { PACKAGE_ROOT } from '../package-info';
|
|
19
22
|
import { managerHealth } from '../manager-health';
|
|
23
|
+
import { holdManagerVault } from './fleet-sync-vault';
|
|
20
24
|
import { readFileSync } from 'node:fs';
|
|
21
|
-
import { homedir } from 'node:os';
|
|
25
|
+
import { homedir, userInfo } from 'node:os';
|
|
22
26
|
import {
|
|
23
27
|
MACOS_MANAGER,
|
|
24
28
|
managerHost,
|
|
@@ -47,9 +51,22 @@ export async function convergeManager(
|
|
|
47
51
|
if (identity.status === 'pass') {
|
|
48
52
|
// Launchers and scheduled jobs on the manager read the installed copy.
|
|
49
53
|
if (input.apply) installConfig();
|
|
50
|
-
checks.push(
|
|
54
|
+
checks.push(
|
|
55
|
+
input.apply
|
|
56
|
+
? await convergeManagerPolicy(run, definition, host)
|
|
57
|
+
: await managerPower(run, input.workspace, host)
|
|
58
|
+
);
|
|
59
|
+
if (input.apply)
|
|
60
|
+
checks.push(await managerPower(run, input.workspace, host));
|
|
51
61
|
checks.push(await managerBootRecovery(run, input.workspace, host));
|
|
52
62
|
checks.push(await managerDocker(run));
|
|
63
|
+
// The renewal job is a launch agent: macOS managers only.
|
|
64
|
+
if (host.platform === 'macOS')
|
|
65
|
+
checks.push(
|
|
66
|
+
...(await attempt('manager:vault-renewal', () =>
|
|
67
|
+
holdManagerVault(run, { apply: input.apply, root: PACKAGE_ROOT })
|
|
68
|
+
))
|
|
69
|
+
);
|
|
53
70
|
// Each enabled IDE: its settings, pinned version and policy.
|
|
54
71
|
checks.push(
|
|
55
72
|
...(await managerChecks(enabled(idesOf(definition), IDES), {
|
|
@@ -202,7 +219,8 @@ export async function managerAgentProfiles(
|
|
|
202
219
|
export async function managerIdentity(
|
|
203
220
|
id: string,
|
|
204
221
|
run: Runner,
|
|
205
|
-
host: ManagerHost = MACOS_MANAGER
|
|
222
|
+
host: ManagerHost = MACOS_MANAGER,
|
|
223
|
+
platform: NodeJS.Platform = process.platform
|
|
206
224
|
): Promise<Check> {
|
|
207
225
|
const local = (await host.localHostName(run)) ?? '';
|
|
208
226
|
return local === id
|
|
@@ -216,26 +234,34 @@ export async function managerIdentity(
|
|
|
216
234
|
status: 'fail',
|
|
217
235
|
summary: 'FLEET_NOT_MANAGER',
|
|
218
236
|
remedy: {
|
|
219
|
-
action:
|
|
237
|
+
action:
|
|
238
|
+
platform === 'darwin'
|
|
239
|
+
? `Run fleet-sync on the manager (${id}). Workers are never controlled from another worker; change the manager only by editing manager in fleet.json and moving its singleton services.`
|
|
240
|
+
: `This machine runs ${platform}, and the manager is always a macOS machine. Run fleet-sync on the manager (${id}), or from a worker that can SSH to it.`,
|
|
220
241
|
},
|
|
221
242
|
};
|
|
222
243
|
}
|
|
223
244
|
|
|
224
245
|
/** Workers depend on the manager's services, so it never sleeps on AC power.
|
|
225
246
|
* Measures every setting `ansible/macos/manager.yml` declares, from the same
|
|
226
|
-
* policy file the playbook applies.
|
|
247
|
+
* policy file the playbook applies. A setting pmset does not list on AC power
|
|
248
|
+
* is one this Mac does not have (a virtual Mac has no womp): the playbook
|
|
249
|
+
* skips it, so it is not applicable here either.
|
|
227
250
|
*/
|
|
228
251
|
export async function managerPower(
|
|
229
252
|
run: Runner,
|
|
230
253
|
workspace = '.',
|
|
231
254
|
host: ManagerHost = MACOS_MANAGER
|
|
232
255
|
): Promise<Check> {
|
|
233
|
-
const drifted = await host.powerDrift(run);
|
|
256
|
+
const { drifted, notApplicable } = await host.powerDrift(run);
|
|
234
257
|
return drifted.length === 0
|
|
235
258
|
? {
|
|
236
259
|
id: 'manager:power',
|
|
237
260
|
status: 'pass',
|
|
238
|
-
summary:
|
|
261
|
+
summary:
|
|
262
|
+
notApplicable.length === 0
|
|
263
|
+
? 'The manager never sleeps on AC power.'
|
|
264
|
+
: `The manager never sleeps on AC power; not applicable on this Mac: ${notApplicable.join(' ')}.`,
|
|
239
265
|
}
|
|
240
266
|
: {
|
|
241
267
|
id: 'manager:power',
|
|
@@ -244,7 +270,7 @@ export async function managerPower(
|
|
|
244
270
|
? 'MANAGER_SLEEPS_ON_POWER'
|
|
245
271
|
: `MANAGER_POWER_POLICY_DRIFT ${drifted.join(' ')}`,
|
|
246
272
|
remedy: {
|
|
247
|
-
action: `Workers lose the CLI proxy and Beszel hub while the manager sleeps.
|
|
273
|
+
action: `Workers lose the CLI proxy and Beszel hub while the manager sleeps. Grant the manager its power settings once, as an administrator; every fleet-sync then applies the policy with no prompt: cd ${workspace} && ${ansibleCommand().join(' ')} -e user=$(id -un) -K ansible/macos/manager-privilege.yml`,
|
|
248
274
|
},
|
|
249
275
|
};
|
|
250
276
|
}
|
|
@@ -283,6 +309,95 @@ export async function managerBootRecovery(
|
|
|
283
309
|
};
|
|
284
310
|
}
|
|
285
311
|
|
|
312
|
+
/** The receipt of the manager's own playbook, beside the workers' ones. */
|
|
313
|
+
export const MANAGER_RECEIPT = 'macos-manager';
|
|
314
|
+
|
|
315
|
+
/** On every applying sync the manager applies `ansible/macos/manager.yml`
|
|
316
|
+
* like a worker applies its baseline: in check mode first, then for real,
|
|
317
|
+
* with a receipt that skips an unchanged playbook while its postconditions
|
|
318
|
+
* hold, and a full re-apply at least once a day. It never asks for a
|
|
319
|
+
* password: what needs root goes through the grant manager-privilege.yml
|
|
320
|
+
* installs once, and the playbook skips it without the grant.
|
|
321
|
+
*/
|
|
322
|
+
export async function convergeManagerPolicy(
|
|
323
|
+
run: Runner,
|
|
324
|
+
definition: Pick<FleetDefinition, 'manager'>,
|
|
325
|
+
host: ManagerHost = MACOS_MANAGER,
|
|
326
|
+
{
|
|
327
|
+
root = PACKAGE_ROOT,
|
|
328
|
+
scratch,
|
|
329
|
+
receipts,
|
|
330
|
+
uid = userInfo().uid,
|
|
331
|
+
now,
|
|
332
|
+
}: {
|
|
333
|
+
root?: string;
|
|
334
|
+
scratch?: string;
|
|
335
|
+
receipts?: string;
|
|
336
|
+
uid?: number;
|
|
337
|
+
now?: number;
|
|
338
|
+
} = {}
|
|
339
|
+
): Promise<Check> {
|
|
340
|
+
if (host.platform !== 'macOS')
|
|
341
|
+
return {
|
|
342
|
+
id: 'manager:playbook',
|
|
343
|
+
status: 'not-applicable',
|
|
344
|
+
summary: 'The manager playbook is for a macOS manager.',
|
|
345
|
+
};
|
|
346
|
+
const result = await runAnsibleWithReceipt(
|
|
347
|
+
run,
|
|
348
|
+
join(root, 'ansible/macos/manager.yml'),
|
|
349
|
+
{
|
|
350
|
+
cwd: join(root, 'ansible/macos'),
|
|
351
|
+
timeoutMs: 300_000,
|
|
352
|
+
input: JSON.stringify({
|
|
353
|
+
fleet_watch_minutes: scheduleIntervals(definition.manager).watchMinutes,
|
|
354
|
+
}),
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
name: MANAGER_RECEIPT,
|
|
358
|
+
sources: [join(root, 'ansible/macos')],
|
|
359
|
+
checkFirst: true,
|
|
360
|
+
verify: async () =>
|
|
361
|
+
(await host.powerDrift(run)).drifted.length === 0 &&
|
|
362
|
+
(await watchLoaded(run, uid)),
|
|
363
|
+
...(receipts ? { directory: receipts } : {}),
|
|
364
|
+
...(scratch ? { scratch } : {}),
|
|
365
|
+
...(now === undefined ? {} : { now }),
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
if (result.ok)
|
|
369
|
+
return {
|
|
370
|
+
id: 'manager:playbook',
|
|
371
|
+
status: 'pass',
|
|
372
|
+
summary: result.cached
|
|
373
|
+
? 'The manager playbook is unchanged since it was last applied, and its postconditions hold.'
|
|
374
|
+
: 'The manager playbook was checked, then applied.',
|
|
375
|
+
};
|
|
376
|
+
return {
|
|
377
|
+
id: 'manager:playbook',
|
|
378
|
+
status: 'fail',
|
|
379
|
+
summary:
|
|
380
|
+
result.stage === 'check'
|
|
381
|
+
? 'MANAGER_PLAYBOOK_CHECK_FAILED'
|
|
382
|
+
: 'MANAGER_PLAYBOOK_FAILED',
|
|
383
|
+
remedy: {
|
|
384
|
+
action: `Run the playbook in check mode on the manager and read the failing task: ${ansibleCommand().join(' ')} --check --diff ${join(root, 'ansible/macos/manager.yml')}. Nothing in it needs a password; what needs root comes from the one grant (manager-privilege.yml).`,
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Whether launchd holds the fleet-watch job manager.yml installs. Reads only. */
|
|
390
|
+
export async function watchLoaded(run: Runner, uid: number): Promise<boolean> {
|
|
391
|
+
return (
|
|
392
|
+
await run(
|
|
393
|
+
['/bin/launchctl', 'print', `gui/${uid}/${macos.fleetWatch.label}`],
|
|
394
|
+
{
|
|
395
|
+
timeoutMs: 10_000,
|
|
396
|
+
}
|
|
397
|
+
)
|
|
398
|
+
).ok;
|
|
399
|
+
}
|
|
400
|
+
|
|
286
401
|
/** The Docker Desktop release `mise.toml` pins, as the engine names itself. */
|
|
287
402
|
export function pinnedDockerDesktop(root = PACKAGE_ROOT): string {
|
|
288
403
|
const { vars } = Bun.TOML.parse(
|
|
@@ -51,17 +51,19 @@ const COLUMNS: { title: string; matches: (id: string) => boolean }[] = [
|
|
|
51
51
|
},
|
|
52
52
|
];
|
|
53
53
|
|
|
54
|
-
const ORDER: Status[] = ['fail', 'warn', 'skip', 'pass'];
|
|
54
|
+
const ORDER: Status[] = ['fail', 'warn', 'skip', 'not-applicable', 'pass'];
|
|
55
55
|
const WORD: Record<Status, string> = {
|
|
56
56
|
fail: 'FAIL',
|
|
57
57
|
warn: 'WARN',
|
|
58
58
|
skip: 'SKIP',
|
|
59
|
+
'not-applicable': 'N/A',
|
|
59
60
|
pass: 'PASS',
|
|
60
61
|
};
|
|
61
62
|
const SYMBOL: Record<Status, string> = {
|
|
62
63
|
fail: '✖',
|
|
63
64
|
warn: '▲',
|
|
64
65
|
skip: '○',
|
|
66
|
+
'not-applicable': '–',
|
|
65
67
|
pass: '✔',
|
|
66
68
|
};
|
|
67
69
|
|
|
@@ -104,7 +106,7 @@ export function managerText(
|
|
|
104
106
|
const lines: string[] = [header(c, report, failing, options)];
|
|
105
107
|
lines.push('', ...grid(c, report.nodes, mark, options.color === true));
|
|
106
108
|
lines.push('', totals(c, counts, options.color === true));
|
|
107
|
-
for (const status of ['fail', 'warn', 'skip'] as const) {
|
|
109
|
+
for (const status of ['fail', 'warn', 'skip', 'not-applicable'] as const) {
|
|
108
110
|
const problems = groupProblems(
|
|
109
111
|
all.filter((entry) => entry.check.status === status)
|
|
110
112
|
);
|
|
@@ -120,6 +122,8 @@ export function managerText(
|
|
|
120
122
|
lines.push(
|
|
121
123
|
`${c.dim('report ')} ${options.reportPath} ${c.dim('(full JSON; or rerun with --json)')}`
|
|
122
124
|
);
|
|
125
|
+
if (report.enrolled?.length)
|
|
126
|
+
lines.push(`${c.dim('enrolled ')} ${report.enrolled.join(', ')}`);
|
|
123
127
|
if (report.candidates)
|
|
124
128
|
lines.push(
|
|
125
129
|
`${c.dim('tailnet ')} ${report.candidates} unmanaged device(s) seen; discovery never enrolls them`
|
|
@@ -138,10 +142,11 @@ export function stableUrls(text: string): string {
|
|
|
138
142
|
return text.replace(TAILNET_URL, 'its private tailnet address');
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
const SECTION: Record<'fail' | 'warn' | 'skip', string> = {
|
|
145
|
+
const SECTION: Record<'fail' | 'warn' | 'skip' | 'not-applicable', string> = {
|
|
142
146
|
fail: 'Failing — fix these',
|
|
143
147
|
warn: 'Warnings — outside the fleet; sync still succeeds',
|
|
144
148
|
skip: 'Not observable on this hardware',
|
|
149
|
+
'not-applicable': 'Not applicable to this machine — with the reason',
|
|
145
150
|
};
|
|
146
151
|
|
|
147
152
|
function header(
|
|
@@ -268,22 +273,32 @@ function totals(
|
|
|
268
273
|
counts: Record<Status, number>,
|
|
269
274
|
color: boolean
|
|
270
275
|
): string {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
// Not-applicable checks are listed only on machines that have some.
|
|
277
|
+
return ORDER.filter((status) => status !== 'not-applicable' || counts[status])
|
|
278
|
+
.map((status) => {
|
|
279
|
+
const text = `${color ? `${SYMBOL[status]} ` : ''}${counts[status]} ${LABEL[status]}`;
|
|
280
|
+
// A zero count is not a signal: never show red/yellow for "0 failing".
|
|
281
|
+
return counts[status] ? paint(c, status, text) : c.dim(text);
|
|
282
|
+
})
|
|
283
|
+
.join(c.dim(' '));
|
|
276
284
|
}
|
|
277
285
|
|
|
278
286
|
const LABEL: Record<Status, string> = {
|
|
279
287
|
fail: 'failing',
|
|
280
288
|
warn: 'warnings',
|
|
281
289
|
skip: 'skipped',
|
|
290
|
+
'not-applicable': 'not applicable',
|
|
282
291
|
pass: 'passing',
|
|
283
292
|
};
|
|
284
293
|
|
|
285
294
|
function count(checks: Check[]): Record<Status, number> {
|
|
286
|
-
const counts: Record<Status, number> = {
|
|
295
|
+
const counts: Record<Status, number> = {
|
|
296
|
+
pass: 0,
|
|
297
|
+
warn: 0,
|
|
298
|
+
fail: 0,
|
|
299
|
+
skip: 0,
|
|
300
|
+
'not-applicable': 0,
|
|
301
|
+
};
|
|
287
302
|
for (const check of checks)
|
|
288
303
|
counts[
|
|
289
304
|
blocking(check) || check.status !== 'fail' ? check.status : 'warn'
|
|
@@ -295,7 +310,12 @@ function worst(checks: Check[]): Status | null {
|
|
|
295
310
|
if (!checks.length) return null;
|
|
296
311
|
if (checks.some(blocking)) return 'fail';
|
|
297
312
|
if (
|
|
298
|
-
checks.some(
|
|
313
|
+
checks.some(
|
|
314
|
+
(check) =>
|
|
315
|
+
check.status !== 'pass' &&
|
|
316
|
+
check.status !== 'skip' &&
|
|
317
|
+
check.status !== 'not-applicable'
|
|
318
|
+
)
|
|
299
319
|
)
|
|
300
320
|
return 'warn';
|
|
301
321
|
return checks.every((check) => check.status === 'skip') ? 'skip' : 'pass';
|
|
@@ -306,7 +326,7 @@ function paint(c: ChalkInstance, status: Status, text: string): string {
|
|
|
306
326
|
? c.red(text)
|
|
307
327
|
: status === 'warn'
|
|
308
328
|
? c.yellow(text)
|
|
309
|
-
: status === 'skip'
|
|
329
|
+
: status === 'skip' || status === 'not-applicable'
|
|
310
330
|
? c.dim(text)
|
|
311
331
|
: c.green(text);
|
|
312
332
|
}
|