@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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createConnection, isIP } from 'node:net';
|
|
2
|
+
import { record } from './config';
|
|
3
|
+
import { fleet } from './fleet-config';
|
|
4
|
+
import { workerExposure, type WorkerExposure } from './exposure';
|
|
5
|
+
import { MONITORING_AGENT_PORT } from './worker-exposure';
|
|
6
|
+
import type { Check, NodeReport, Runner } from './types';
|
|
7
|
+
|
|
8
|
+
/** Opens one TCP connection and closes it at once: whether it was accepted. */
|
|
9
|
+
export type Connect = (
|
|
10
|
+
host: string,
|
|
11
|
+
port: number,
|
|
12
|
+
timeoutMs: number
|
|
13
|
+
) => Promise<boolean>;
|
|
14
|
+
|
|
15
|
+
export const tcpConnect: Connect = (host, port, timeoutMs) =>
|
|
16
|
+
new Promise((resolve) => {
|
|
17
|
+
const socket = createConnection({ host, port });
|
|
18
|
+
const done = (accepted: boolean) => {
|
|
19
|
+
socket.destroy();
|
|
20
|
+
resolve(accepted);
|
|
21
|
+
};
|
|
22
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
23
|
+
socket.once('connect', () => done(true));
|
|
24
|
+
socket.once('error', () => done(false));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const UNRESTRICTED = 'WORKER_PUBLIC_PORT_UNRESTRICTED';
|
|
28
|
+
|
|
29
|
+
const PRIVATE_V4: [number, number][] = [
|
|
30
|
+
[0x00000000, 8], // this network
|
|
31
|
+
[0x0a000000, 8],
|
|
32
|
+
[0x64400000, 10], // carrier-grade NAT and the tailnet
|
|
33
|
+
[0x7f000000, 8],
|
|
34
|
+
[0xa9fe0000, 16], // link-local
|
|
35
|
+
[0xac100000, 12],
|
|
36
|
+
[0xc0a80000, 16],
|
|
37
|
+
[0xe0000000, 3], // multicast and reserved
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** An address the internet routes to: not private, loopback, link-local,
|
|
41
|
+
* shared (which includes the tailnet), unique-local or multicast. */
|
|
42
|
+
export function isPublicAddress(address: string): boolean {
|
|
43
|
+
if (isIP(address) === 4) {
|
|
44
|
+
const value =
|
|
45
|
+
address
|
|
46
|
+
.split('.')
|
|
47
|
+
.reduce((total, part) => total * 256 + Number(part), 0) >>> 0;
|
|
48
|
+
return !PRIVATE_V4.some(
|
|
49
|
+
([network, bits]) =>
|
|
50
|
+
value >>> (32 - bits) === (network >>> (32 - bits)) >>> 0
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (isIP(address) === 6) {
|
|
54
|
+
const lower = address.toLowerCase();
|
|
55
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
|
56
|
+
if (mapped) return isPublicAddress(mapped[1]!);
|
|
57
|
+
return !(
|
|
58
|
+
lower === '::' ||
|
|
59
|
+
lower === '::1' ||
|
|
60
|
+
/^fe[89ab]/.test(lower) ||
|
|
61
|
+
/^f[cd]/.test(lower) ||
|
|
62
|
+
/^ff/.test(lower)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The public addresses Tailscale reaches a worker at (`Addrs` of its peer in
|
|
69
|
+
* `tailscale status --json`), each once. */
|
|
70
|
+
export function publicEndpoints(
|
|
71
|
+
status: unknown,
|
|
72
|
+
tailscaleName: string
|
|
73
|
+
): string[] {
|
|
74
|
+
if (!record(status) || !record(status['Peer'])) return [];
|
|
75
|
+
const name = tailscaleName.toLowerCase();
|
|
76
|
+
const peer = Object.values(status['Peer']).find(
|
|
77
|
+
(entry) =>
|
|
78
|
+
record(entry) &&
|
|
79
|
+
typeof entry['DNSName'] === 'string' &&
|
|
80
|
+
entry['DNSName'].replace(/\.$/, '').toLowerCase() === name
|
|
81
|
+
);
|
|
82
|
+
if (!record(peer) || !Array.isArray(peer['Addrs'])) return [];
|
|
83
|
+
const found = new Set<string>();
|
|
84
|
+
for (const entry of peer['Addrs'].slice(0, 32)) {
|
|
85
|
+
if (typeof entry !== 'string') continue;
|
|
86
|
+
const host = entry.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
|
|
87
|
+
if (isPublicAddress(host)) found.add(host);
|
|
88
|
+
}
|
|
89
|
+
return [...found];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** worker:public-reachability, from the manager: none of the fleet's ports
|
|
93
|
+
* accepts a connection on the worker's public address. Optional: the manager
|
|
94
|
+
* may share the worker's network, so a failure here never blocks readiness;
|
|
95
|
+
* worker:exposure is measured on the worker itself. Names the service and
|
|
96
|
+
* port, never the address.
|
|
97
|
+
*/
|
|
98
|
+
export async function publicReachabilityCheck(
|
|
99
|
+
rules: WorkerExposure,
|
|
100
|
+
addresses: string[],
|
|
101
|
+
connect: Connect = tcpConnect
|
|
102
|
+
): Promise<Check> {
|
|
103
|
+
if (addresses.length === 0)
|
|
104
|
+
return {
|
|
105
|
+
id: 'worker:public-reachability',
|
|
106
|
+
status: 'not-applicable',
|
|
107
|
+
required: false,
|
|
108
|
+
summary:
|
|
109
|
+
'Tailscale knows no public address for this worker: there is nothing outside the tailnet to try.',
|
|
110
|
+
};
|
|
111
|
+
const ports = [
|
|
112
|
+
...(rules.policy.publicSsh ? [] : [{ name: 'SSH', port: rules.sshPort }]),
|
|
113
|
+
{ name: 'T3', port: rules.t3Port },
|
|
114
|
+
{ name: 'the monitoring agent', port: MONITORING_AGENT_PORT },
|
|
115
|
+
].filter(
|
|
116
|
+
(entry) =>
|
|
117
|
+
!rules.policy.publicPorts.some(
|
|
118
|
+
(open) => open.protocol === 'tcp' && open.port === entry.port
|
|
119
|
+
)
|
|
120
|
+
);
|
|
121
|
+
const accepted: string[] = [];
|
|
122
|
+
for (const entry of ports)
|
|
123
|
+
for (const address of addresses)
|
|
124
|
+
if (await connect(address, entry.port, 3_000)) {
|
|
125
|
+
accepted.push(`${entry.name} (tcp ${entry.port})`);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
const tried = ports.map((entry) => `${entry.name} (tcp ${entry.port})`);
|
|
129
|
+
if (accepted.length)
|
|
130
|
+
return {
|
|
131
|
+
id: 'worker:public-reachability',
|
|
132
|
+
status: 'fail',
|
|
133
|
+
required: false,
|
|
134
|
+
summary: `${UNRESTRICTED} ${accepted.join(', ')} accepted a connection on the worker's public address.`,
|
|
135
|
+
remedy: {
|
|
136
|
+
action:
|
|
137
|
+
'Run devfleet fleet-sync, which loads the fleet firewall on the worker, then read worker:exposure for the unit that listens. When the manager shares the worker’s network, confirm from a machine outside it before changing anything.',
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
id: 'worker:public-reachability',
|
|
142
|
+
status: 'pass',
|
|
143
|
+
required: false,
|
|
144
|
+
summary: `No connection was accepted on the worker's public address: ${tried.join(', ')}.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Adds worker:public-reachability to the report of every declared worker
|
|
149
|
+
* the tailnet knows. */
|
|
150
|
+
export async function appendPublicReachability(
|
|
151
|
+
reports: NodeReport[],
|
|
152
|
+
run: Runner,
|
|
153
|
+
connect: Connect = tcpConnect
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const status = await run(['tailscale', 'status', '--json'], {
|
|
156
|
+
timeoutMs: 15_000,
|
|
157
|
+
});
|
|
158
|
+
let parsed: unknown = null;
|
|
159
|
+
try {
|
|
160
|
+
parsed = status.ok ? JSON.parse(status.stdout) : null;
|
|
161
|
+
} catch {
|
|
162
|
+
/* Raw status is never reported. */
|
|
163
|
+
}
|
|
164
|
+
for (const machine of fleet.workers) {
|
|
165
|
+
const name = machine.network?.tailscaleName;
|
|
166
|
+
const report = reports.find(
|
|
167
|
+
(entry) => entry.node === name || entry.node === machine.id
|
|
168
|
+
);
|
|
169
|
+
if (!report || !name) continue;
|
|
170
|
+
report.checks.push(
|
|
171
|
+
parsed === null
|
|
172
|
+
? {
|
|
173
|
+
id: 'worker:public-reachability',
|
|
174
|
+
status: 'skip',
|
|
175
|
+
required: false,
|
|
176
|
+
summary:
|
|
177
|
+
'Tailscale status is unavailable on the manager, so the public address of this worker is unknown.',
|
|
178
|
+
}
|
|
179
|
+
: await publicReachabilityCheck(
|
|
180
|
+
workerExposure(fleet, machine.id),
|
|
181
|
+
publicEndpoints(parsed, name),
|
|
182
|
+
connect
|
|
183
|
+
)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/remote-bootstrap.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { record } from './config';
|
|
|
24
24
|
import { installedDefinition, type FleetDefinition } from './definition';
|
|
25
25
|
import { cleanCheck, validCheck } from './protocol';
|
|
26
26
|
import { UBUNTU_RELEASES } from './platform/ubuntu-release';
|
|
27
|
+
import { T3_RESTART_MODES } from './platform/t3-service';
|
|
27
28
|
|
|
28
29
|
export interface BootstrapPayload {
|
|
29
30
|
revision: string;
|
|
@@ -33,7 +34,12 @@ export interface BootstrapPayload {
|
|
|
33
34
|
authorizeSudo?: string;
|
|
34
35
|
/** The fleet configuration every node keeps at ~/.config/dev-fleet/fleet.json. */
|
|
35
36
|
config?: string;
|
|
37
|
+
/** `fleet-sync --restart-busy`: a worker's T3 restarts for a new release or
|
|
38
|
+
* policy even while agent turns run. By default it waits for idle.
|
|
39
|
+
*/
|
|
40
|
+
restartBusy?: boolean;
|
|
36
41
|
}
|
|
42
|
+
|
|
37
43
|
/** No manager tool installation; Git/source preparation is part of apply approval. */
|
|
38
44
|
export async function prepareBootstrap(
|
|
39
45
|
root: string,
|
|
@@ -107,16 +113,21 @@ async function bundleRevision(
|
|
|
107
113
|
await unlink(path).catch(() => undefined);
|
|
108
114
|
}
|
|
109
115
|
}
|
|
116
|
+
/** `tailnetKey` is a single-use enrolment key for a worker not on the tailnet
|
|
117
|
+
* yet. It travels on the session's stdin, after the configuration, and only
|
|
118
|
+
* to a bootstrap script that reads it: never as an argument.
|
|
119
|
+
*/
|
|
110
120
|
export async function bootstrapRemote(
|
|
111
121
|
node: FleetNode,
|
|
112
122
|
payload: BootstrapPayload,
|
|
113
123
|
run: Runner,
|
|
114
|
-
|
|
124
|
+
options: Follow & { tailnetKey?: string } = {}
|
|
115
125
|
): Promise<NodeReport> {
|
|
126
|
+
const { tailnetKey, ...follow } = options;
|
|
116
127
|
const clock = follow.clock ?? realClock;
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
const transfer = cached
|
|
128
|
+
const held = await cachedRevision(node, payload.revision, run, clock);
|
|
129
|
+
if ('id' in held) return { node: node.id, checks: [held] };
|
|
130
|
+
const transfer = held.cached
|
|
120
131
|
? { mode: 'cached', bundle: '' }
|
|
121
132
|
: { mode: 'transfer', bundle: payload.bundle };
|
|
122
133
|
const command = [
|
|
@@ -129,14 +140,22 @@ export async function bootstrapRemote(
|
|
|
129
140
|
node.id,
|
|
130
141
|
transfer.mode,
|
|
131
142
|
UBUNTU_RELEASES.map((entry) => entry.release).join(' '),
|
|
143
|
+
T3_RESTART_MODES[payload.restartBusy ? 1 : 0],
|
|
132
144
|
]
|
|
133
145
|
.map(shellQuote)
|
|
134
146
|
.join(' ');
|
|
135
147
|
const checks = await bootstrapSessions(
|
|
136
148
|
node,
|
|
137
149
|
payload,
|
|
138
|
-
{
|
|
139
|
-
|
|
150
|
+
{
|
|
151
|
+
command,
|
|
152
|
+
input: bootstrapInput(
|
|
153
|
+
payload.config,
|
|
154
|
+
transfer.bundle,
|
|
155
|
+
readsTailnetKey(payload.script) ? (tailnetKey ?? '') : null
|
|
156
|
+
),
|
|
157
|
+
},
|
|
158
|
+
{ run, follow: { ...follow, clock, earlier: held.earlier } }
|
|
140
159
|
);
|
|
141
160
|
return { node: node.id, checks };
|
|
142
161
|
}
|
|
@@ -198,23 +217,37 @@ function locked(checks: Check[]): boolean {
|
|
|
198
217
|
|
|
199
218
|
/** What the bootstrap reads on stdin: one line with the fleet configuration
|
|
200
219
|
* (installed under the remote lock, so overlapping runs never both write it),
|
|
201
|
-
*
|
|
220
|
+
* one line with the tailnet enrolment key (empty when there is none; absent
|
|
221
|
+
* for a script of an earlier revision, which reads no such line), then the
|
|
222
|
+
* bundle when the worker lacks the revision.
|
|
202
223
|
*/
|
|
203
224
|
export function bootstrapInput(
|
|
204
225
|
config: string | undefined,
|
|
205
|
-
bundle: string
|
|
226
|
+
bundle: string,
|
|
227
|
+
tailnetKey: string | null = null
|
|
206
228
|
): string {
|
|
207
229
|
const line = config ? Buffer.from(config).toString('base64') : '';
|
|
208
|
-
return
|
|
230
|
+
return tailnetKey === null
|
|
231
|
+
? `${line}\n${bundle}`
|
|
232
|
+
: `${line}\n${tailnetKey}\n${bundle}`;
|
|
209
233
|
}
|
|
210
234
|
|
|
235
|
+
/** A bootstrap script reads the key line since the revision that added it. */
|
|
236
|
+
export function readsTailnetKey(script: string): boolean {
|
|
237
|
+
return script.includes('read -r tailnet_key');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Whether the worker holds a clean checkout of the revision, and when the
|
|
241
|
+
* operation its record says had finished there started: that one ended
|
|
242
|
+
* before this run, so its result is never taken for this run's.
|
|
243
|
+
*/
|
|
211
244
|
async function cachedRevision(
|
|
212
245
|
node: FleetNode,
|
|
213
246
|
revision: string,
|
|
214
247
|
run: Runner,
|
|
215
248
|
clock: Clock
|
|
216
|
-
): Promise<boolean | Check> {
|
|
217
|
-
const command = `
|
|
249
|
+
): Promise<{ cached: boolean; earlier: number | null } | Check> {
|
|
250
|
+
const command = `r="$HOME/.local/share/personal/fleet"; sed -n 's/^{.*"started":\\([0-9][0-9]*\\),.*"finished":[0-9].*$/finished=\\1/p' "$r/remote-operation.json" 2>/dev/null | head -n 1; d="$r/revisions/${revision}"; test ! -L "$d" && test -d "$d/.git" && test "$(git -C "$d" rev-parse HEAD 2>/dev/null)" = '${revision}' && test "$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" = "$d" && clean=$(git -C "$d" status --porcelain 2>/dev/null) && test -z "$clean" && printf ready`;
|
|
218
251
|
const result = await retrying(
|
|
219
252
|
() => run([...sshArgs(node), command], { timeoutMs: 20_000 }),
|
|
220
253
|
clock
|
|
@@ -223,7 +256,12 @@ async function cachedRevision(
|
|
|
223
256
|
const failure = sshFailure(result);
|
|
224
257
|
if (result.reason !== 'exit' || failure.id !== 'ssh:probe') return failure;
|
|
225
258
|
}
|
|
226
|
-
|
|
259
|
+
const lines = result.stdout.split('\n');
|
|
260
|
+
const finished = /^finished=(\d{1,12})$/.exec(lines[0] ?? '');
|
|
261
|
+
return {
|
|
262
|
+
cached: result.ok && lines.at(-1) === 'ready',
|
|
263
|
+
earlier: finished ? Number(finished[1]) : null,
|
|
264
|
+
};
|
|
227
265
|
}
|
|
228
266
|
|
|
229
267
|
function parseEvidence(result: {
|
package/src/remote-operation.ts
CHANGED
|
@@ -148,16 +148,30 @@ function followable(operation: Operation, revision: string): boolean {
|
|
|
148
148
|
);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
/** The result of a finished operation of this revision, unless it is the
|
|
152
|
+
* operation that had already finished before this run opened a session: a
|
|
153
|
+
* re-sync of the same revision finds the record of the last run still there,
|
|
154
|
+
* and its result is not this run's.
|
|
155
|
+
*/
|
|
156
|
+
function finishedWith(
|
|
157
|
+
operation: Operation,
|
|
158
|
+
revision: string,
|
|
159
|
+
earlier: number | null | undefined
|
|
160
|
+
): Check[] | null {
|
|
152
161
|
return !operation.held &&
|
|
153
162
|
operation.finished &&
|
|
154
|
-
operation.revision === revision
|
|
163
|
+
operation.revision === revision &&
|
|
164
|
+
!(typeof earlier === 'number' && operation.started === earlier)
|
|
155
165
|
? operation.result
|
|
156
166
|
: null;
|
|
157
167
|
}
|
|
158
168
|
|
|
159
169
|
export interface Follow {
|
|
160
170
|
clock?: Clock;
|
|
171
|
+
/** When the operation that had finished on the worker before this run
|
|
172
|
+
* began had started there (the worker's clock, in seconds), when known.
|
|
173
|
+
*/
|
|
174
|
+
earlier?: number | null;
|
|
161
175
|
/** Told the phase and how long the operation has run, at every look. */
|
|
162
176
|
progress?: (phase: string) => void;
|
|
163
177
|
}
|
|
@@ -176,7 +190,7 @@ export async function followOperation(
|
|
|
176
190
|
const deadline = clock.now() + FOLLOW_AT_MOST_MS;
|
|
177
191
|
let operation = await inspectOperation(node, run, clock);
|
|
178
192
|
if (!operation) return null;
|
|
179
|
-
const done = finishedWith(operation, revision);
|
|
193
|
+
const done = finishedWith(operation, revision, options.earlier);
|
|
180
194
|
if (done) return [attached(operation, 'finished'), ...done];
|
|
181
195
|
if (!followable(operation, revision)) return null;
|
|
182
196
|
let last: Operation = operation;
|
|
@@ -186,7 +200,7 @@ export async function followOperation(
|
|
|
186
200
|
operation = await inspectOperation(node, run, clock);
|
|
187
201
|
if (!operation) continue;
|
|
188
202
|
last = operation;
|
|
189
|
-
const result = finishedWith(operation, revision);
|
|
203
|
+
const result = finishedWith(operation, revision, options.earlier);
|
|
190
204
|
if (result) return [attached(operation, 'followed'), ...result];
|
|
191
205
|
if (!followable(operation, revision)) return null;
|
|
192
206
|
}
|
package/src/repair-plan.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { checkOwner } from './check-owners';
|
|
2
|
-
import { AUTOMATED_REPAIRS, recipe } from './repair-catalog';
|
|
2
|
+
import { AUTOMATED_REPAIRS, REPAIR_RECIPES, recipe } from './repair-catalog';
|
|
3
3
|
import type { RepairRecipe } from './repair-catalog';
|
|
4
4
|
import type { Check, FleetNode, NodeReport, Fingerprint } from './types';
|
|
5
5
|
|
|
@@ -18,22 +18,33 @@ export interface RepairStep extends RepairRecipe {
|
|
|
18
18
|
manual?: { check: string; reason: string }[];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const UNOWNED =
|
|
22
|
+
'No repair recipe is registered for this check: follow its remedy.';
|
|
23
|
+
|
|
24
|
+
/** What repairs a check: the recipe its owner names, or why only a person
|
|
25
|
+
* can. An `auth:` check is repaired by the login recipe of its name; one
|
|
26
|
+
* with no such recipe is a person's, for the reason `login:*` gives. */
|
|
27
|
+
export function checkRepair(
|
|
28
|
+
id: string
|
|
29
|
+
): { recipe: string } | { manual: string } {
|
|
30
|
+
const owner = checkOwner(id);
|
|
31
|
+
if (!owner?.repair) return { manual: owner?.manual ?? UNOWNED };
|
|
32
|
+
if (owner.repair !== 'login:*') return { recipe: owner.repair };
|
|
33
|
+
const login = `login:${id.slice('auth:'.length)}`;
|
|
34
|
+
return REPAIR_RECIPES.some((entry) => entry.id === login)
|
|
35
|
+
? { recipe: login }
|
|
36
|
+
: { manual: checkOwner(login)?.manual ?? UNOWNED };
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
export function repairId(check: Check): string {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
return owner.repair === 'login:*'
|
|
25
|
-
? `login:${check.id.slice('auth:'.length)}`
|
|
26
|
-
: owner.repair;
|
|
40
|
+
const repair = checkRepair(check.id);
|
|
41
|
+
return 'recipe' in repair ? repair.recipe : 'operator';
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
/** Why a check is left to a person, or null when a recipe repairs it. */
|
|
30
45
|
export function manualReason(check: Check): string | null {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
return (
|
|
34
|
-
owner?.manual ??
|
|
35
|
-
'No repair recipe is registered for this check: follow its remedy.'
|
|
36
|
-
);
|
|
46
|
+
const repair = checkRepair(check.id);
|
|
47
|
+
return 'manual' in repair ? repair.manual : null;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
/** Every nonpassing check is accounted for; unknown checks remain operator work. */
|
package/src/report.ts
CHANGED
|
@@ -6,6 +6,7 @@ export function makeReport(command: string, nodes: NodeReport[]): Report {
|
|
|
6
6
|
warn: 0,
|
|
7
7
|
fail: 0,
|
|
8
8
|
skip: 0,
|
|
9
|
+
'not-applicable': 0,
|
|
9
10
|
};
|
|
10
11
|
for (const node of nodes)
|
|
11
12
|
for (const check of node.checks) summary[check.status] += 1;
|
|
@@ -46,7 +47,7 @@ export function renderReport(report: Report): string {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
lines.push(
|
|
49
|
-
`\n${report.summary.pass} pass, ${report.summary.warn} warn, ${report.summary.fail} fail, ${report.summary.skip} skip`
|
|
50
|
+
`\n${report.summary.pass} pass, ${report.summary.warn} warn, ${report.summary.fail} fail, ${report.summary.skip} skip${report.summary['not-applicable'] ? `, ${report.summary['not-applicable']} not applicable` : ''}`
|
|
50
51
|
);
|
|
51
52
|
return lines.join('\n');
|
|
52
53
|
}
|
package/src/schedule-check.ts
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
+
import macos from '../ansible/macos/policy.json';
|
|
2
|
+
import type { ManagerDefinition } from './definition';
|
|
1
3
|
import type { History } from './state/history';
|
|
2
4
|
import type { Check } from './types';
|
|
3
5
|
|
|
4
|
-
/** How often the manager's schedule runs fleet-sync. */
|
|
5
|
-
export const SCHEDULE_INTERVAL_SECONDS =
|
|
6
|
+
/** How often the manager's schedule runs fleet-sync by default. */
|
|
7
|
+
export const SCHEDULE_INTERVAL_SECONDS = macos.fleetSync.intervalMinutes * 60;
|
|
8
|
+
|
|
9
|
+
/** How often the manager's scheduled jobs run: `manager.schedule` in
|
|
10
|
+
* fleet.json, or the defaults of `ansible/macos/policy.json`.
|
|
11
|
+
*/
|
|
12
|
+
export function scheduleIntervals(
|
|
13
|
+
manager: Pick<ManagerDefinition, 'schedule'>
|
|
14
|
+
): {
|
|
15
|
+
syncSeconds: number;
|
|
16
|
+
watchMinutes: number;
|
|
17
|
+
} {
|
|
18
|
+
return {
|
|
19
|
+
syncSeconds:
|
|
20
|
+
(manager.schedule?.syncMinutes ?? macos.fleetSync.intervalMinutes) * 60,
|
|
21
|
+
watchMinutes:
|
|
22
|
+
manager.schedule?.watchMinutes ?? macos.fleetWatch.intervalMinutes,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
6
25
|
|
|
7
26
|
/** `manager:schedule`: a schedule that is on must have succeeded within two
|
|
8
27
|
* of its intervals. A schedule that was never turned on is no check at all.
|
package/src/secrets.ts
CHANGED
|
@@ -26,6 +26,13 @@ export const OPTIONAL_SECRET_NAMES = [
|
|
|
26
26
|
// Where fleet-watch notifies (notify.targets).
|
|
27
27
|
'notify-webhook-url',
|
|
28
28
|
'notify-ntfy-url',
|
|
29
|
+
// A short-lived, single-use, tagged Tailscale auth key a new worker joins
|
|
30
|
+
// the tailnet with (fleet-node-seed).
|
|
31
|
+
'tailnet-auth-key',
|
|
32
|
+
// An API credential of the tailnet's control plane (tailnet in fleet.json)
|
|
33
|
+
// that creates one single-use key per new worker. It stays on the manager:
|
|
34
|
+
// no worker ever receives it.
|
|
35
|
+
'tailnet-admin',
|
|
29
36
|
] as const;
|
|
30
37
|
export type SecretName =
|
|
31
38
|
(typeof SECRET_NAMES)[number] | (typeof OPTIONAL_SECRET_NAMES)[number];
|
package/src/service-auth.ts
CHANGED
|
@@ -43,68 +43,75 @@ export async function codingAuth(
|
|
|
43
43
|
];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/** CLIs with no sign-in of their own to check, and why.
|
|
47
|
-
*
|
|
46
|
+
/** Registered agent CLIs with no sign-in of their own to check, and why.
|
|
47
|
+
* Each is `not-applicable` on the proxy route and says where its access is
|
|
48
|
+
* proven instead; none is a silent skip.
|
|
48
49
|
*/
|
|
49
50
|
export const PROXY_ROUTED: Record<string, string> = {
|
|
50
51
|
gemini:
|
|
51
52
|
'gemini signs in through the CLI proxy and has no T3 launcher; provider:gemini:inference proves its route from this node.',
|
|
52
53
|
opencode:
|
|
53
54
|
'opencode signs in through the CLI proxy; agent:muse:session and the per-account agent:opencode sessions prove it from this node.',
|
|
55
|
+
grok: 'grok reads the proxy key from its environment and has no grok login; agent:grok:session proves it from this node.',
|
|
56
|
+
antigravity:
|
|
57
|
+
'antigravity signs in through the CLI proxy; agent:antigravity:session proves it from this node.',
|
|
54
58
|
};
|
|
55
59
|
|
|
60
|
+
/** The services a computer signs in to, each checked as `auth:<id>`. */
|
|
61
|
+
const SERVICES: Service[] = [
|
|
62
|
+
{
|
|
63
|
+
id: 'railway',
|
|
64
|
+
command: ['railway', 'whoami', '--json'],
|
|
65
|
+
action: 'Run railway login on this computer.',
|
|
66
|
+
valid: (result) => typeof json(result)?.['id'] === 'string',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'vercel',
|
|
70
|
+
command: ['vercel', 'whoami'],
|
|
71
|
+
action: 'Run vercel login on this computer.',
|
|
72
|
+
valid: (result) =>
|
|
73
|
+
/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/.test(result.stdout.trim()),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'cloudflare',
|
|
77
|
+
command: ['wrangler', 'whoami', '--json'],
|
|
78
|
+
action: 'Run wrangler login on this computer.',
|
|
79
|
+
valid: (result) => json(result)?.['loggedIn'] === true,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'aws',
|
|
83
|
+
command: [
|
|
84
|
+
'aws',
|
|
85
|
+
'sts',
|
|
86
|
+
'get-caller-identity',
|
|
87
|
+
'--output',
|
|
88
|
+
'json',
|
|
89
|
+
'--no-cli-pager',
|
|
90
|
+
],
|
|
91
|
+
action:
|
|
92
|
+
'Select and authenticate the intended AWS profile on this computer.',
|
|
93
|
+
valid: (result) =>
|
|
94
|
+
typeof json(result)?.['Account'] === 'string' &&
|
|
95
|
+
typeof json(result)?.['Arn'] === 'string',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'npm',
|
|
99
|
+
command: ['npm', 'whoami', '--registry=https://registry.npmjs.org/'],
|
|
100
|
+
action: 'Run npm login only if this computer should publish packages.',
|
|
101
|
+
valid: (result) =>
|
|
102
|
+
/^[a-z0-9][a-z0-9._-]{0,213}$/.test(result.stdout.trim()),
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
export const SERVICE_IDS = SERVICES.map((service) => service.id);
|
|
106
|
+
|
|
56
107
|
export async function serviceAuth(run: Runner): Promise<Check[]> {
|
|
57
|
-
const services: Service[] = [
|
|
58
|
-
{
|
|
59
|
-
id: 'railway',
|
|
60
|
-
command: ['railway', 'whoami', '--json'],
|
|
61
|
-
action: 'Run railway login on this computer.',
|
|
62
|
-
valid: (result) => typeof json(result)?.['id'] === 'string',
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
id: 'vercel',
|
|
66
|
-
command: ['vercel', 'whoami'],
|
|
67
|
-
action: 'Run vercel login on this computer.',
|
|
68
|
-
valid: (result) =>
|
|
69
|
-
/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/.test(result.stdout.trim()),
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
id: 'cloudflare',
|
|
73
|
-
command: ['wrangler', 'whoami', '--json'],
|
|
74
|
-
action: 'Run wrangler login on this computer.',
|
|
75
|
-
valid: (result) => json(result)?.['loggedIn'] === true,
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
id: 'aws',
|
|
79
|
-
command: [
|
|
80
|
-
'aws',
|
|
81
|
-
'sts',
|
|
82
|
-
'get-caller-identity',
|
|
83
|
-
'--output',
|
|
84
|
-
'json',
|
|
85
|
-
'--no-cli-pager',
|
|
86
|
-
],
|
|
87
|
-
action:
|
|
88
|
-
'Select and authenticate the intended AWS profile on this computer.',
|
|
89
|
-
valid: (result) =>
|
|
90
|
-
typeof json(result)?.['Account'] === 'string' &&
|
|
91
|
-
typeof json(result)?.['Arn'] === 'string',
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
id: 'npm',
|
|
95
|
-
command: ['npm', 'whoami', '--registry=https://registry.npmjs.org/'],
|
|
96
|
-
action: 'Run npm login only if this computer should publish packages.',
|
|
97
|
-
valid: (result) =>
|
|
98
|
-
/^[a-z0-9][a-z0-9._-]{0,213}$/.test(result.stdout.trim()),
|
|
99
|
-
},
|
|
100
|
-
];
|
|
101
108
|
const checks: Check[] = [];
|
|
102
|
-
for (const service of
|
|
109
|
+
for (const service of SERVICES) checks.push(await serviceCheck(service, run));
|
|
103
110
|
checks.push(await copilotCheck(run));
|
|
104
111
|
for (const [id, reason] of Object.entries(PROXY_ROUTED))
|
|
105
112
|
checks.push({
|
|
106
113
|
id: `auth:${id}`,
|
|
107
|
-
status: '
|
|
114
|
+
status: 'not-applicable',
|
|
108
115
|
required: false,
|
|
109
116
|
summary: `AUTH_NOT_SUPPORTED_ON_ROUTE: ${reason}`,
|
|
110
117
|
});
|