@crvouga/dev-fleet 0.3.0 → 0.4.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 +22 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/policy.json +11 -1
- package/ansible/monitoring/compose.yml +10 -0
- package/ansible/monitoring/container-watchdog.py +118 -0
- package/ansible/monitoring/hub-darwin.yml +146 -0
- package/ansible/net/remote-probe.sh +5 -0
- package/ansible/ubuntu/baseline.yml +2 -0
- package/ansible/ubuntu/disk-gc.py +25 -3
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +50 -1
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +1 -1
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/checkpoint.py +1 -1
- package/docs/authentication.md +90 -0
- package/docs/declared.md +2 -0
- package/docs/machine-setup.md +22 -12
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/ubuntu-sync.md +7 -6
- package/examples/fleet.json +19 -1
- package/llms.txt +2 -0
- package/mise.toml +7 -0
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +178 -1
- package/schema/output/check.schema.json +23 -0
- package/schema/output/error.schema.json +14 -0
- package/schema/output/fleet-report.schema.json +79 -0
- package/schema/output/fleet-sync.schema.json +112 -0
- package/schema/output/report.schema.json +38 -0
- package/src/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-manager.ts +11 -0
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +3 -1
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +90 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +51 -0
- package/src/cli/registry.ts +13 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +142 -5
- package/src/git-identity.ts +160 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +12 -2
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +16 -5
- package/src/notify.ts +239 -0
- package/src/remote-bootstrap.ts +87 -25
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +32 -0
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { record } from './config';
|
|
2
|
+
import { cleanCheck, validCheck } from './protocol';
|
|
3
|
+
import { sshArgs, sshFailure } from './ssh';
|
|
4
|
+
import type { Check, CommandResult, FleetNode, Runner } from './types';
|
|
5
|
+
|
|
6
|
+
/** How the manager waits: a pause, and the time now. Tests answer both. */
|
|
7
|
+
export interface Clock {
|
|
8
|
+
pause(ms: number): Promise<void>;
|
|
9
|
+
now(): number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const realClock: Clock = {
|
|
13
|
+
pause: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
14
|
+
now: () => Date.now(),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A session that could not be opened, or was lost, is tried again after
|
|
18
|
+
* these pauses: three attempts over thirty seconds.
|
|
19
|
+
*/
|
|
20
|
+
export const RETRY_PAUSES_MS = [10_000, 20_000];
|
|
21
|
+
/** How often an operation the manager follows is looked at again. */
|
|
22
|
+
export const FOLLOW_EVERY_MS = 15_000;
|
|
23
|
+
/** As long as a bootstrap may run. */
|
|
24
|
+
export const FOLLOW_AT_MOST_MS = 5_400_000;
|
|
25
|
+
|
|
26
|
+
/** Failures of the connection, not of the worker: worth another attempt. A
|
|
27
|
+
* changed host key, a refused login or an unknown name never are.
|
|
28
|
+
*/
|
|
29
|
+
export function transient(result: CommandResult): boolean {
|
|
30
|
+
if (result.ok) return false;
|
|
31
|
+
const { id } = sshFailure(result);
|
|
32
|
+
return ['ssh:disconnected', 'ssh:network', 'ssh:timeout'].includes(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Runs a short SSH session, again after a pause while its connection fails. */
|
|
36
|
+
export async function retrying(
|
|
37
|
+
attempt: () => Promise<CommandResult>,
|
|
38
|
+
clock: Clock
|
|
39
|
+
): Promise<CommandResult> {
|
|
40
|
+
let result = await attempt();
|
|
41
|
+
for (const pause of RETRY_PAUSES_MS) {
|
|
42
|
+
if (!transient(result)) return result;
|
|
43
|
+
await clock.pause(pause);
|
|
44
|
+
result = await attempt();
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a worker says about the fleet operation on it. */
|
|
50
|
+
export interface Operation {
|
|
51
|
+
/** The operation lock is held by a running process. */
|
|
52
|
+
held: boolean;
|
|
53
|
+
/** The process that recorded the operation is still running. */
|
|
54
|
+
alive: boolean;
|
|
55
|
+
revision: string | null;
|
|
56
|
+
phase: string | null;
|
|
57
|
+
/** Seconds since the epoch, on the worker's clock. */
|
|
58
|
+
started: number | null;
|
|
59
|
+
finished: boolean;
|
|
60
|
+
/** What node convergence reported, once the operation finished. */
|
|
61
|
+
result: Check[] | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read-only. Prints whether the lock is held, whether the recorded process
|
|
65
|
+
* runs, the operation record and its result, one per line. Holding the lock
|
|
66
|
+
* for a moment to test it is not possible while another holds it, and takes
|
|
67
|
+
* nothing from anyone when nobody does.
|
|
68
|
+
*/
|
|
69
|
+
export const OPERATION_PROBE = [
|
|
70
|
+
'r="$HOME/.local/share/personal/fleet"',
|
|
71
|
+
'l="$r/remote-operation.lock"',
|
|
72
|
+
'held=no',
|
|
73
|
+
'if [ -f "$l" ] && [ ! -L "$l" ]; then if command -v flock >/dev/null 2>&1; then flock -n "$l" true || held=yes; else python3 -c \'import fcntl, sys; fcntl.flock(open(sys.argv[1], "a"), fcntl.LOCK_EX | fcntl.LOCK_NB)\' "$l" 2>/dev/null || held=yes; fi; fi',
|
|
74
|
+
'op=$(head -c 4096 "$r/remote-operation.json" 2>/dev/null | head -n 1)',
|
|
75
|
+
'pid=$(printf %s "$op" | sed -n \'s/.*"pid":\\([0-9][0-9]*\\).*/\\1/p\')',
|
|
76
|
+
'alive=no',
|
|
77
|
+
'if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=yes; fi',
|
|
78
|
+
'printf \'held=%s\\nalive=%s\\noperation=%s\\nresult=\' "$held" "$alive" "$op"',
|
|
79
|
+
'head -c 1048576 "$r/remote-operation-result.json" 2>/dev/null | head -n 1',
|
|
80
|
+
"printf '\\n'",
|
|
81
|
+
].join('\n');
|
|
82
|
+
|
|
83
|
+
function field(stdout: string, name: string): string | null {
|
|
84
|
+
const line = stdout.split('\n').find((entry) => entry.startsWith(`${name}=`));
|
|
85
|
+
return line === undefined ? null : line.slice(name.length + 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function json(text: string | null): unknown {
|
|
89
|
+
try {
|
|
90
|
+
return text ? JSON.parse(text) : null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function evidence(value: unknown): Check[] | null {
|
|
97
|
+
return record(value) &&
|
|
98
|
+
value['version'] === 1 &&
|
|
99
|
+
Array.isArray(value['checks']) &&
|
|
100
|
+
value['checks'].length > 0 &&
|
|
101
|
+
value['checks'].length <= 200 &&
|
|
102
|
+
value['checks'].every(validCheck)
|
|
103
|
+
? value['checks'].map(cleanCheck)
|
|
104
|
+
: null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseOperation(stdout: string): Operation | null {
|
|
108
|
+
const held = field(stdout, 'held');
|
|
109
|
+
const alive = field(stdout, 'alive');
|
|
110
|
+
if (held === null || alive === null) return null;
|
|
111
|
+
const operation = json(field(stdout, 'operation'));
|
|
112
|
+
const op = record(operation) ? operation : {};
|
|
113
|
+
const text = (value: unknown, pattern: RegExp): string | null =>
|
|
114
|
+
typeof value === 'string' && pattern.test(value) ? value : null;
|
|
115
|
+
return {
|
|
116
|
+
held: held === 'yes',
|
|
117
|
+
alive: alive === 'yes',
|
|
118
|
+
revision: text(op['revision'], /^[a-f0-9]{40}$/),
|
|
119
|
+
phase: text(op['phase'], /^[a-z0-9-]{1,40}$/),
|
|
120
|
+
started: typeof op['started'] === 'number' ? op['started'] : null,
|
|
121
|
+
finished: typeof op['finished'] === 'number',
|
|
122
|
+
result: evidence(json(field(stdout, 'result'))),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function inspectOperation(
|
|
127
|
+
node: FleetNode,
|
|
128
|
+
run: Runner,
|
|
129
|
+
clock: Clock
|
|
130
|
+
): Promise<Operation | null> {
|
|
131
|
+
const result = await retrying(
|
|
132
|
+
() => run([...sshArgs(node), OPERATION_PROBE], { timeoutMs: 20_000 }),
|
|
133
|
+
clock
|
|
134
|
+
);
|
|
135
|
+
return result.ok ? parseOperation(result.stdout) : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** An operation of this revision whose process still runs: this is the run
|
|
139
|
+
* the manager lost, or one another manager run started; either way it is
|
|
140
|
+
* the run to follow, not one to start beside it.
|
|
141
|
+
*/
|
|
142
|
+
function followable(operation: Operation, revision: string): boolean {
|
|
143
|
+
return (
|
|
144
|
+
operation.held &&
|
|
145
|
+
operation.alive &&
|
|
146
|
+
!operation.finished &&
|
|
147
|
+
operation.revision === revision
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finishedWith(operation: Operation, revision: string): Check[] | null {
|
|
152
|
+
return !operation.held &&
|
|
153
|
+
operation.finished &&
|
|
154
|
+
operation.revision === revision
|
|
155
|
+
? operation.result
|
|
156
|
+
: null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface Follow {
|
|
160
|
+
clock?: Clock;
|
|
161
|
+
/** Told the phase and how long the operation has run, at every look. */
|
|
162
|
+
progress?: (phase: string) => void;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** After a lost session or a held lock: the result of the operation of this
|
|
166
|
+
* revision on the worker, followed to its end when it still runs. Null when
|
|
167
|
+
* there is none to follow: another revision, another holder, or nothing.
|
|
168
|
+
*/
|
|
169
|
+
export async function followOperation(
|
|
170
|
+
node: FleetNode,
|
|
171
|
+
revision: string,
|
|
172
|
+
run: Runner,
|
|
173
|
+
options: Follow = {}
|
|
174
|
+
): Promise<Check[] | null> {
|
|
175
|
+
const clock = options.clock ?? realClock;
|
|
176
|
+
const deadline = clock.now() + FOLLOW_AT_MOST_MS;
|
|
177
|
+
let operation = await inspectOperation(node, run, clock);
|
|
178
|
+
if (!operation) return null;
|
|
179
|
+
const done = finishedWith(operation, revision);
|
|
180
|
+
if (done) return [attached(operation, 'finished'), ...done];
|
|
181
|
+
if (!followable(operation, revision)) return null;
|
|
182
|
+
let last: Operation = operation;
|
|
183
|
+
while (clock.now() < deadline) {
|
|
184
|
+
options.progress?.(describe(last, clock));
|
|
185
|
+
await clock.pause(FOLLOW_EVERY_MS);
|
|
186
|
+
operation = await inspectOperation(node, run, clock);
|
|
187
|
+
if (!operation) continue;
|
|
188
|
+
last = operation;
|
|
189
|
+
const result = finishedWith(operation, revision);
|
|
190
|
+
if (result) return [attached(operation, 'followed'), ...result];
|
|
191
|
+
if (!followable(operation, revision)) return null;
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function describe(operation: Operation, clock: Clock): string {
|
|
197
|
+
const elapsed =
|
|
198
|
+
operation.started === null
|
|
199
|
+
? ''
|
|
200
|
+
: `, ${Math.max(0, Math.round(clock.now() / 1000 - operation.started))} s`;
|
|
201
|
+
return `Following the running operation (${operation.phase ?? 'unknown phase'}${elapsed})`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function attached(_operation: Operation, how: 'finished' | 'followed'): Check {
|
|
205
|
+
return {
|
|
206
|
+
id: 'bootstrap:attached',
|
|
207
|
+
status: 'pass',
|
|
208
|
+
summary:
|
|
209
|
+
how === 'followed'
|
|
210
|
+
? 'The manager lost or could not take this machine’s operation of the same revision, followed it to its end and reports its result.'
|
|
211
|
+
: 'This machine finished the operation of the same revision the manager lost; its result is reported.',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { History } from './state/history';
|
|
2
|
+
import type { Check } from './types';
|
|
3
|
+
|
|
4
|
+
/** How often the manager's schedule runs fleet-sync. */
|
|
5
|
+
export const SCHEDULE_INTERVAL_SECONDS = 1800;
|
|
6
|
+
|
|
7
|
+
/** `manager:schedule`: a schedule that is on must have succeeded within two
|
|
8
|
+
* of its intervals. A schedule that was never turned on is no check at all.
|
|
9
|
+
*/
|
|
10
|
+
export function scheduleCheck(history: History, now: Date): Check | null {
|
|
11
|
+
const { schedule } = history;
|
|
12
|
+
if (!schedule?.enabled) return null;
|
|
13
|
+
const window = 2 * schedule.intervalSeconds * 1000;
|
|
14
|
+
const lastSuccess = history.runs
|
|
15
|
+
.filter((run) => run.trigger === 'schedule' && run.ok)
|
|
16
|
+
.map((run) => Date.parse(run.finishedAt))
|
|
17
|
+
.reduce((latest, at) => Math.max(latest, at), Number.NEGATIVE_INFINITY);
|
|
18
|
+
// A schedule turned on a moment ago has had no chance to run yet.
|
|
19
|
+
const since = Math.max(lastSuccess, Date.parse(schedule.changedAt));
|
|
20
|
+
const minutes = Math.round(schedule.intervalSeconds / 60);
|
|
21
|
+
if (now.getTime() - since <= window)
|
|
22
|
+
return {
|
|
23
|
+
id: 'manager:schedule',
|
|
24
|
+
status: 'pass',
|
|
25
|
+
summary: Number.isFinite(lastSuccess)
|
|
26
|
+
? `The schedule last converged the fleet at ${new Date(lastSuccess).toISOString()}.`
|
|
27
|
+
: 'The schedule was turned on and has not had to run yet.',
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
id: 'manager:schedule',
|
|
31
|
+
status: 'fail',
|
|
32
|
+
summary: Number.isFinite(lastSuccess)
|
|
33
|
+
? `The schedule has not converged the fleet since ${new Date(lastSuccess).toISOString()}, more than two intervals of ${minutes} minutes.`
|
|
34
|
+
: `The schedule has not converged the fleet once since it was turned on, more than two intervals of ${minutes} minutes ago.`,
|
|
35
|
+
remedy: {
|
|
36
|
+
action:
|
|
37
|
+
'Run devfleet fleet-sync --schedule status on the manager to see the last scheduled runs, then devfleet fleet-report for the failing checks. Re-enable it with devfleet fleet-sync --schedule enable --apply --yes when the manager was replaced.',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/secrets.ts
CHANGED
|
@@ -12,7 +12,20 @@ export const SECRET_NAMES = [
|
|
|
12
12
|
'monitoring-tunnel-token',
|
|
13
13
|
'proxy-tunnel-token',
|
|
14
14
|
] as const;
|
|
15
|
-
|
|
15
|
+
/** Secrets only a feature the fleet declares needs: a Vault location may
|
|
16
|
+
* leave them out, and they are then unavailable.
|
|
17
|
+
*/
|
|
18
|
+
export const OPTIONAL_SECRET_NAMES = [
|
|
19
|
+
// The off-machine storage of the monitoring hub's backups
|
|
20
|
+
// (services.monitoring.backups).
|
|
21
|
+
'monitoring-backup-access-key',
|
|
22
|
+
'monitoring-backup-secret-key',
|
|
23
|
+
// Where fleet-watch notifies (notify.targets).
|
|
24
|
+
'notify-webhook-url',
|
|
25
|
+
'notify-ntfy-url',
|
|
26
|
+
] as const;
|
|
27
|
+
export type SecretName =
|
|
28
|
+
(typeof SECRET_NAMES)[number] | (typeof OPTIONAL_SECRET_NAMES)[number];
|
|
16
29
|
|
|
17
30
|
/** A secret's location in a Vault KV v2 mount. */
|
|
18
31
|
export type VaultLocation = { path: string; field: string };
|
|
@@ -24,8 +37,13 @@ export type SecretsConfig =
|
|
|
24
37
|
provider: 'vault';
|
|
25
38
|
address: string;
|
|
26
39
|
mount: string;
|
|
27
|
-
locations:
|
|
40
|
+
locations: VaultLocations;
|
|
28
41
|
};
|
|
42
|
+
export type VaultLocations = Record<
|
|
43
|
+
(typeof SECRET_NAMES)[number],
|
|
44
|
+
VaultLocation
|
|
45
|
+
> &
|
|
46
|
+
Partial<Record<(typeof OPTIONAL_SECRET_NAMES)[number], VaultLocation>>;
|
|
29
47
|
|
|
30
48
|
/** Runs a command and returns its stdout, or null when it fails. */
|
|
31
49
|
export type SecretCommand = (
|
|
@@ -79,6 +97,7 @@ export function secretsProvider(
|
|
|
79
97
|
);
|
|
80
98
|
return async (name) => {
|
|
81
99
|
const location = config.locations[name];
|
|
100
|
+
if (!location) return null;
|
|
82
101
|
return present(
|
|
83
102
|
await run([
|
|
84
103
|
'vault',
|
|
@@ -142,13 +161,12 @@ const PARSERS: Record<string, (value: Fields) => SecretsConfig | null> = {
|
|
|
142
161
|
},
|
|
143
162
|
};
|
|
144
163
|
|
|
145
|
-
function vaultLocations(
|
|
146
|
-
value: unknown
|
|
147
|
-
): Record<SecretName, VaultLocation> | null {
|
|
164
|
+
function vaultLocations(value: unknown): VaultLocations | null {
|
|
148
165
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
149
166
|
const fields = value as Fields;
|
|
150
|
-
if (!only(fields, [...SECRET_NAMES])) return null;
|
|
151
|
-
const
|
|
167
|
+
if (!only(fields, [...SECRET_NAMES, ...OPTIONAL_SECRET_NAMES])) return null;
|
|
168
|
+
const optional = OPTIONAL_SECRET_NAMES.filter((name) => name in fields);
|
|
169
|
+
const valid = [...SECRET_NAMES, ...optional].every((name) => {
|
|
152
170
|
const location = fields[name] as Fields | undefined;
|
|
153
171
|
return (
|
|
154
172
|
location !== null &&
|
|
@@ -158,7 +176,7 @@ function vaultLocations(
|
|
|
158
176
|
text(location['field'])
|
|
159
177
|
);
|
|
160
178
|
});
|
|
161
|
-
return valid ? (fields as
|
|
179
|
+
return valid ? (fields as VaultLocations) : null;
|
|
162
180
|
}
|
|
163
181
|
|
|
164
182
|
/** The `secrets` block of a fleet configuration, or null when it is invalid. */
|
package/src/ssh.ts
CHANGED
|
@@ -20,9 +20,9 @@ export function sshArgs(node: FleetNode): string[] {
|
|
|
20
20
|
'-o',
|
|
21
21
|
'ConnectionAttempts=1',
|
|
22
22
|
'-o',
|
|
23
|
-
'ServerAliveInterval=
|
|
23
|
+
'ServerAliveInterval=15',
|
|
24
24
|
'-o',
|
|
25
|
-
'ServerAliveCountMax=
|
|
25
|
+
'ServerAliveCountMax=4',
|
|
26
26
|
'-o',
|
|
27
27
|
'ClearAllForwardings=yes',
|
|
28
28
|
'-o',
|
|
@@ -66,6 +66,14 @@ const FAILURES = [
|
|
|
66
66
|
action:
|
|
67
67
|
'Use ssh-add -l and ssh -vvv ALIAS locally; check User, IdentityFile, IdentitiesOnly, agent/keychain access and authorized_keys. Do not share debug logs without review.',
|
|
68
68
|
},
|
|
69
|
+
{
|
|
70
|
+
pattern:
|
|
71
|
+
/Connection (?:to \S+ )?closed|Connection reset|Broken pipe|Timeout, server \S+ not responding|client_loop: send disconnect/i,
|
|
72
|
+
id: 'ssh:disconnected',
|
|
73
|
+
summary: 'The SSH session was lost while the operation ran.',
|
|
74
|
+
action:
|
|
75
|
+
'The worker keeps running what it started. Rerun fleet-sync: it follows the operation of the same revision to its end. Check the network between the manager and the worker if it repeats.',
|
|
76
|
+
},
|
|
69
77
|
{
|
|
70
78
|
pattern: /Connection refused|No route to host|Network is unreachable/i,
|
|
71
79
|
id: 'ssh:network',
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { record } from '../config';
|
|
2
|
+
import { databasePath, stateRoot } from './paths';
|
|
3
|
+
import { readDocument, writeDocument } from './store';
|
|
4
|
+
|
|
5
|
+
/** Who started a fleet-sync run: a person (or an agent) at the manager, or
|
|
6
|
+
* the manager's own schedule.
|
|
7
|
+
*/
|
|
8
|
+
export type Trigger = 'operator' | 'schedule';
|
|
9
|
+
|
|
10
|
+
/** One fleet-sync run, as the history keeps it: codes and node names only,
|
|
11
|
+
* never a remedy, a path or anything a subprocess printed.
|
|
12
|
+
*/
|
|
13
|
+
export interface SyncRun {
|
|
14
|
+
startedAt: string;
|
|
15
|
+
finishedAt: string;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
trigger: Trigger;
|
|
18
|
+
revision: string | null;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
exitCode: number;
|
|
21
|
+
/** The IDs of the checks that failed, once each. */
|
|
22
|
+
failing: string[];
|
|
23
|
+
/** The machines that were not ready when the run ended. */
|
|
24
|
+
notReady: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The schedule the manager was last told to keep. */
|
|
28
|
+
export interface ScheduleRecord {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
intervalSeconds: number;
|
|
31
|
+
changedAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface History {
|
|
35
|
+
version: 1;
|
|
36
|
+
schedule?: ScheduleRecord;
|
|
37
|
+
runs: SyncRun[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The history is capped twice: by age and by count, so the document stays
|
|
41
|
+
* far below the fleet state's limit on one document.
|
|
42
|
+
*/
|
|
43
|
+
export const HISTORY_DAYS = 90;
|
|
44
|
+
export const HISTORY_RUNS = 500;
|
|
45
|
+
|
|
46
|
+
const DAY_MS = 86_400_000;
|
|
47
|
+
|
|
48
|
+
function strings(value: unknown): value is string[] {
|
|
49
|
+
return Array.isArray(value) && value.every((v) => typeof v === 'string');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function syncRun(value: unknown): value is SyncRun {
|
|
53
|
+
return (
|
|
54
|
+
record(value) &&
|
|
55
|
+
typeof value['startedAt'] === 'string' &&
|
|
56
|
+
typeof value['finishedAt'] === 'string' &&
|
|
57
|
+
typeof value['durationMs'] === 'number' &&
|
|
58
|
+
(value['trigger'] === 'operator' || value['trigger'] === 'schedule') &&
|
|
59
|
+
(value['revision'] === null || typeof value['revision'] === 'string') &&
|
|
60
|
+
typeof value['ok'] === 'boolean' &&
|
|
61
|
+
typeof value['exitCode'] === 'number' &&
|
|
62
|
+
strings(value['failing']) &&
|
|
63
|
+
strings(value['notReady'])
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scheduleRecord(value: unknown): value is ScheduleRecord {
|
|
68
|
+
return (
|
|
69
|
+
record(value) &&
|
|
70
|
+
typeof value['enabled'] === 'boolean' &&
|
|
71
|
+
typeof value['intervalSeconds'] === 'number' &&
|
|
72
|
+
typeof value['changedAt'] === 'string'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A document that cannot be read is an empty history: it only ever
|
|
77
|
+
* describes the past, and the next run starts it again.
|
|
78
|
+
*/
|
|
79
|
+
export function parseHistory(text: string | null): History {
|
|
80
|
+
let value: unknown = null;
|
|
81
|
+
try {
|
|
82
|
+
value = text === null ? null : JSON.parse(text);
|
|
83
|
+
} catch {
|
|
84
|
+
value = null;
|
|
85
|
+
}
|
|
86
|
+
if (!record(value) || value['version'] !== 1) return { version: 1, runs: [] };
|
|
87
|
+
return {
|
|
88
|
+
version: 1,
|
|
89
|
+
...(scheduleRecord(value['schedule'])
|
|
90
|
+
? { schedule: value['schedule'] }
|
|
91
|
+
: {}),
|
|
92
|
+
runs: Array.isArray(value['runs']) ? value['runs'].filter(syncRun) : [],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readHistory(env = process.env): History {
|
|
97
|
+
return parseHistory(readDocument(databasePath(stateRoot(env)), 'history'));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeHistory(history: History, env: NodeJS.ProcessEnv): void {
|
|
101
|
+
writeDocument(
|
|
102
|
+
databasePath(stateRoot(env)),
|
|
103
|
+
'history',
|
|
104
|
+
JSON.stringify(history) + '\n'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Keeps the runs of the last HISTORY_DAYS days, and at most HISTORY_RUNS. */
|
|
109
|
+
export function capped(runs: SyncRun[], now: Date): SyncRun[] {
|
|
110
|
+
const oldest = now.getTime() - HISTORY_DAYS * DAY_MS;
|
|
111
|
+
return runs
|
|
112
|
+
.filter((run) => Date.parse(run.finishedAt) >= oldest)
|
|
113
|
+
.slice(-HISTORY_RUNS);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function appendRun(
|
|
117
|
+
run: SyncRun,
|
|
118
|
+
env = process.env,
|
|
119
|
+
now = new Date()
|
|
120
|
+
): History {
|
|
121
|
+
const history = readHistory(env);
|
|
122
|
+
const next = { ...history, runs: capped([...history.runs, run], now) };
|
|
123
|
+
writeHistory(next, env);
|
|
124
|
+
return next;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function recordSchedule(
|
|
128
|
+
schedule: ScheduleRecord,
|
|
129
|
+
env = process.env
|
|
130
|
+
): void {
|
|
131
|
+
writeHistory({ ...readHistory(env), schedule }, env);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What a fleet-sync report says about one run. */
|
|
135
|
+
export function summarizeRun(
|
|
136
|
+
data: unknown,
|
|
137
|
+
timing: { startedAt: Date; finishedAt: Date },
|
|
138
|
+
trigger: Trigger,
|
|
139
|
+
exitCode: number
|
|
140
|
+
): SyncRun {
|
|
141
|
+
const report = record(data) ? data : {};
|
|
142
|
+
const checks = (value: unknown): Record<string, unknown>[] =>
|
|
143
|
+
Array.isArray(value) ? value.filter(record) : [];
|
|
144
|
+
const nodes = checks(report['nodes']);
|
|
145
|
+
const failing = new Set<string>();
|
|
146
|
+
for (const check of [
|
|
147
|
+
...checks(report['checks']),
|
|
148
|
+
...nodes.flatMap((node) => checks(node['checks'])),
|
|
149
|
+
])
|
|
150
|
+
if (check['status'] === 'fail' && typeof check['id'] === 'string')
|
|
151
|
+
failing.add(check['id']);
|
|
152
|
+
const notReady = nodes
|
|
153
|
+
.filter((node) =>
|
|
154
|
+
checks(node['checks']).some((check) => check['status'] === 'fail')
|
|
155
|
+
)
|
|
156
|
+
.map((node) => node['node'])
|
|
157
|
+
.filter((node): node is string => typeof node === 'string');
|
|
158
|
+
return {
|
|
159
|
+
startedAt: timing.startedAt.toISOString(),
|
|
160
|
+
finishedAt: timing.finishedAt.toISOString(),
|
|
161
|
+
durationMs: timing.finishedAt.getTime() - timing.startedAt.getTime(),
|
|
162
|
+
trigger,
|
|
163
|
+
revision:
|
|
164
|
+
typeof report['desiredRevision'] === 'string'
|
|
165
|
+
? report['desiredRevision']
|
|
166
|
+
: null,
|
|
167
|
+
ok: exitCode === 0 && report['ok'] !== false,
|
|
168
|
+
exitCode,
|
|
169
|
+
failing: [...failing].sort(),
|
|
170
|
+
notReady: [...new Set(notReady)].sort(),
|
|
171
|
+
};
|
|
172
|
+
}
|
package/src/state/store.ts
CHANGED
|
@@ -9,7 +9,14 @@ import { FleetUsageError } from '../errors';
|
|
|
9
9
|
* connection to each worker, `enrollment` the approved and applied revision.
|
|
10
10
|
*/
|
|
11
11
|
export const DOCUMENTS = ['definition', 'roster', 'enrollment'] as const;
|
|
12
|
+
/** Kept when there is something to keep; a fleet without them is complete:
|
|
13
|
+
* `history` is the record of recent fleet-sync runs and of the schedule.
|
|
14
|
+
*/
|
|
15
|
+
export const OPTIONAL_DOCUMENTS = ['history'] as const;
|
|
12
16
|
export type DocumentName = (typeof DOCUMENTS)[number];
|
|
17
|
+
/** Every document the fleet state may hold. */
|
|
18
|
+
export type StoredName = DocumentName | (typeof OPTIONAL_DOCUMENTS)[number];
|
|
19
|
+
const KNOWN: readonly string[] = [...DOCUMENTS, ...OPTIONAL_DOCUMENTS];
|
|
13
20
|
|
|
14
21
|
/** One version of the whole database. Every change replaces it, so two copies
|
|
15
22
|
* hold the same state exactly when their heads are equal.
|
|
@@ -21,7 +28,7 @@ export interface Head {
|
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
export interface DocumentInfo {
|
|
24
|
-
name:
|
|
31
|
+
name: StoredName;
|
|
25
32
|
bytes: number;
|
|
26
33
|
updatedAt: string;
|
|
27
34
|
}
|
|
@@ -100,7 +107,7 @@ function tables(db: Database): boolean {
|
|
|
100
107
|
);
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
export function readDocument(file: string, name:
|
|
110
|
+
export function readDocument(file: string, name: StoredName): string | null {
|
|
104
111
|
return reading(file, (db) => {
|
|
105
112
|
if (!tables(db)) return null;
|
|
106
113
|
const row = db
|
|
@@ -119,7 +126,7 @@ export function listDocuments(file: string): DocumentInfo[] {
|
|
|
119
126
|
'SELECT name, length(CAST(body AS BLOB)) AS bytes, updated_at AS updatedAt FROM documents ORDER BY name'
|
|
120
127
|
)
|
|
121
128
|
.all() as DocumentInfo[];
|
|
122
|
-
return rows.filter((row) =>
|
|
129
|
+
return rows.filter((row) => KNOWN.includes(row.name));
|
|
123
130
|
}) ?? []
|
|
124
131
|
);
|
|
125
132
|
}
|
|
@@ -138,7 +145,7 @@ export function readHead(file: string): Head | null {
|
|
|
138
145
|
*/
|
|
139
146
|
export function writeDocument(
|
|
140
147
|
file: string,
|
|
141
|
-
name:
|
|
148
|
+
name: StoredName,
|
|
142
149
|
body: string
|
|
143
150
|
): Head {
|
|
144
151
|
if (Buffer.byteLength(body) > MAX_DOCUMENT_BYTES)
|
package/src/tailscale.ts
CHANGED
|
@@ -10,6 +10,9 @@ export interface TailnetComputer {
|
|
|
10
10
|
ips: string[];
|
|
11
11
|
online: boolean;
|
|
12
12
|
self: boolean;
|
|
13
|
+
/** When the node key expires, in milliseconds since the epoch; absent when
|
|
14
|
+
* it never does (tagged nodes, or expiry disabled in the admin console). */
|
|
15
|
+
keyExpiry?: number;
|
|
13
16
|
}
|
|
14
17
|
export interface Tailnet {
|
|
15
18
|
computers: TailnetComputer[];
|
|
@@ -103,6 +106,7 @@ function parseComputer(value: unknown, self: boolean): TailnetComputer {
|
|
|
103
106
|
OS: os,
|
|
104
107
|
TailscaleIPs: ips,
|
|
105
108
|
Online: online,
|
|
109
|
+
KeyExpiry: keyExpiry,
|
|
106
110
|
} = value;
|
|
107
111
|
if (
|
|
108
112
|
![id, name, os].every(
|
|
@@ -129,6 +133,53 @@ function parseComputer(value: unknown, self: boolean): TailnetComputer {
|
|
|
129
133
|
ips: ips as string[],
|
|
130
134
|
online,
|
|
131
135
|
self,
|
|
136
|
+
...expiry(keyExpiry),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function expiry(value: unknown): { keyExpiry?: number } {
|
|
141
|
+
if (typeof value !== 'string') return {};
|
|
142
|
+
const time = Date.parse(value);
|
|
143
|
+
// Tailscale writes the zero time for a key that never expires.
|
|
144
|
+
return Number.isFinite(time) && time > 0 ? { keyExpiry: time } : {};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const DAY_MS = 86_400_000;
|
|
148
|
+
const KEY_EXPIRY = { warnDays: 14, failDays: 3 };
|
|
149
|
+
|
|
150
|
+
/** A node drops off the tailnet on the day its key expires. The check warns
|
|
151
|
+
* two weeks ahead and fails three days ahead, like a T3 link that expires.
|
|
152
|
+
*/
|
|
153
|
+
export function keyExpiryCheck(
|
|
154
|
+
computer: TailnetComputer | undefined,
|
|
155
|
+
now = Date.now()
|
|
156
|
+
): Check {
|
|
157
|
+
const id = 'tailscale:key-expiry';
|
|
158
|
+
if (!computer)
|
|
159
|
+
return { id, status: 'skip', summary: 'TAILSCALE_KEY_EXPIRY_UNOBSERVED' };
|
|
160
|
+
if (computer.keyExpiry === undefined)
|
|
161
|
+
return {
|
|
162
|
+
id,
|
|
163
|
+
status: 'pass',
|
|
164
|
+
summary: 'The Tailscale node key of this machine does not expire.',
|
|
165
|
+
};
|
|
166
|
+
const days = Math.floor((computer.keyExpiry - now) / DAY_MS);
|
|
167
|
+
const remedy = {
|
|
168
|
+
action: computer.self
|
|
169
|
+
? 'Renew the node key of the manager before it expires: tailscale up --force-reauth on the manager, or disable key expiry for it in the Tailscale admin console.'
|
|
170
|
+
: 'Disable key expiry for the worker in the Tailscale admin console, or tag it (tagged nodes do not expire). After an expiry, re-join it as docs/operations.md describes (Tailscale key expiry).',
|
|
171
|
+
};
|
|
172
|
+
if (days < 0)
|
|
173
|
+
return { id, status: 'fail', summary: 'TAILSCALE_KEY_EXPIRED', remedy };
|
|
174
|
+
const summary = `TAILSCALE_KEY_EXPIRES_SOON in ${days} days`;
|
|
175
|
+
if (days <= KEY_EXPIRY.failDays)
|
|
176
|
+
return { id, status: 'fail', summary, remedy };
|
|
177
|
+
if (days <= KEY_EXPIRY.warnDays)
|
|
178
|
+
return { id, status: 'warn', summary, remedy };
|
|
179
|
+
return {
|
|
180
|
+
id,
|
|
181
|
+
status: 'pass',
|
|
182
|
+
summary: `The Tailscale node key expires in ${days} days.`,
|
|
132
183
|
};
|
|
133
184
|
}
|
|
134
185
|
|
package/src/toolchain-checks.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
selectionArguments,
|
|
4
|
+
type ToolSelection,
|
|
5
|
+
} from '../bootstrap/worker-tools';
|
|
2
6
|
import { record } from './config';
|
|
3
7
|
import type { Check, Runner } from './types';
|
|
4
8
|
|
|
5
9
|
export async function toolchainChecks(
|
|
6
10
|
root: string,
|
|
7
11
|
run: Runner,
|
|
8
|
-
worker
|
|
12
|
+
/** A worker's own tool selection; absent on the manager. */
|
|
13
|
+
worker?: ToolSelection
|
|
9
14
|
): Promise<Check[]> {
|
|
10
15
|
const result = await run(doctorArguments(root, worker), {
|
|
11
16
|
cwd: root,
|
|
@@ -97,12 +102,12 @@ function toolCheck(value: unknown): Check | null {
|
|
|
97
102
|
};
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
function doctorArguments(root: string, worker?:
|
|
105
|
+
function doctorArguments(root: string, worker?: ToolSelection): string[] {
|
|
101
106
|
return [
|
|
102
107
|
'bun',
|
|
103
108
|
join(root, 'bootstrap/doctor.ts'),
|
|
104
109
|
'--json',
|
|
105
110
|
'--docker',
|
|
106
|
-
...(worker ? ['--worker'] : []),
|
|
111
|
+
...(worker ? ['--worker', ...selectionArguments(worker)] : []),
|
|
107
112
|
];
|
|
108
113
|
}
|