@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,198 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fleet, secret } from '../fleet-config';
|
|
4
|
+
import {
|
|
5
|
+
deliver,
|
|
6
|
+
observe,
|
|
7
|
+
parseMemory,
|
|
8
|
+
transitions,
|
|
9
|
+
type Notification,
|
|
10
|
+
type NotifyConfig,
|
|
11
|
+
} from '../notify';
|
|
12
|
+
import { connection } from '../state/connection';
|
|
13
|
+
import type { Bucket } from '../state/bucket';
|
|
14
|
+
import type { SecretsProvider } from '../secrets';
|
|
15
|
+
import type { Check, NodeReport } from '../types';
|
|
16
|
+
import type { CommandOutcome } from './contracts';
|
|
17
|
+
|
|
18
|
+
export interface FleetWatchOptions {
|
|
19
|
+
test?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What the watcher remembers between runs, kept in the bucket beside the
|
|
23
|
+
* fleet state. Its time is the heartbeat an off-machine check reads.
|
|
24
|
+
*/
|
|
25
|
+
export interface WatchStore {
|
|
26
|
+
read: () => Promise<string | null>;
|
|
27
|
+
write: (text: string) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FleetWatchServices {
|
|
31
|
+
notify: NotifyConfig | undefined;
|
|
32
|
+
/** The machines as `fleet-status --workers` sees them now. */
|
|
33
|
+
status: () => Promise<NodeReport[]>;
|
|
34
|
+
store: WatchStore | null;
|
|
35
|
+
secrets: SecretsProvider;
|
|
36
|
+
send: typeof fetch;
|
|
37
|
+
now: () => Date;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Compares the fleet's health with the previous run and notifies on
|
|
41
|
+
* transitions only. `--test` sends one test notification and nothing else.
|
|
42
|
+
*/
|
|
43
|
+
export async function fleetWatch(
|
|
44
|
+
options: FleetWatchOptions,
|
|
45
|
+
services: FleetWatchServices
|
|
46
|
+
): Promise<CommandOutcome> {
|
|
47
|
+
const { notify } = services;
|
|
48
|
+
if (options.test) {
|
|
49
|
+
const checks = notify
|
|
50
|
+
? await deliver(
|
|
51
|
+
notify,
|
|
52
|
+
{ event: 'test', node: 'fleet-watch', checks: [] },
|
|
53
|
+
services.secrets,
|
|
54
|
+
services.send
|
|
55
|
+
)
|
|
56
|
+
: [unconfigured('fail')];
|
|
57
|
+
return outcome(checks, [
|
|
58
|
+
{ event: 'test', node: 'fleet-watch', checks: [] },
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
const previous = services.store
|
|
62
|
+
? parseMemory(await services.store.read().catch(() => null))
|
|
63
|
+
: null;
|
|
64
|
+
const observed = observe(await services.status());
|
|
65
|
+
const { notifications, memory } = transitions(
|
|
66
|
+
previous,
|
|
67
|
+
observed,
|
|
68
|
+
services.now(),
|
|
69
|
+
notify?.repeatHours ?? 0
|
|
70
|
+
);
|
|
71
|
+
const checks: Check[] = [];
|
|
72
|
+
for (const notification of notifications)
|
|
73
|
+
if (notify)
|
|
74
|
+
checks.push(
|
|
75
|
+
...(await deliver(
|
|
76
|
+
notify,
|
|
77
|
+
notification,
|
|
78
|
+
services.secrets,
|
|
79
|
+
services.send
|
|
80
|
+
))
|
|
81
|
+
);
|
|
82
|
+
if (!notify) checks.push(unconfigured('warn'));
|
|
83
|
+
checks.push(await heartbeat(services.store, JSON.stringify(memory)));
|
|
84
|
+
return outcome(checks, notifications);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function unconfigured(status: 'warn' | 'fail'): Check {
|
|
88
|
+
return {
|
|
89
|
+
id: 'notify:configured',
|
|
90
|
+
status,
|
|
91
|
+
summary: 'NOTIFY_NOT_CONFIGURED',
|
|
92
|
+
remedy: {
|
|
93
|
+
action:
|
|
94
|
+
'Add a "notify" block to fleet.json with its targets, and store each target address as its secret (notify-webhook-url, notify-ntfy-url).',
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function heartbeat(
|
|
100
|
+
store: WatchStore | null,
|
|
101
|
+
text: string
|
|
102
|
+
): Promise<Check> {
|
|
103
|
+
const id = 'watch:heartbeat';
|
|
104
|
+
if (!store)
|
|
105
|
+
return {
|
|
106
|
+
id,
|
|
107
|
+
status: 'warn',
|
|
108
|
+
summary: 'WATCH_HEARTBEAT_NO_BUCKET',
|
|
109
|
+
remedy: {
|
|
110
|
+
action:
|
|
111
|
+
'Name the bucket under "state" in fleet.json: the heartbeat and what the watcher remembers are kept there.',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
return store.write(text).then(
|
|
115
|
+
() => ({
|
|
116
|
+
id,
|
|
117
|
+
status: 'pass' as const,
|
|
118
|
+
summary: 'The heartbeat and the health of each machine were stored.',
|
|
119
|
+
}),
|
|
120
|
+
() => ({
|
|
121
|
+
id,
|
|
122
|
+
status: 'fail' as const,
|
|
123
|
+
summary: 'WATCH_HEARTBEAT_UNWRITTEN',
|
|
124
|
+
remedy: {
|
|
125
|
+
action:
|
|
126
|
+
'Check the bucket credentials this run was given (devfleet fleet-state shows the connection).',
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function outcome(
|
|
133
|
+
checks: Check[],
|
|
134
|
+
notifications: Notification[]
|
|
135
|
+
): CommandOutcome {
|
|
136
|
+
const ok = !checks.some((check) => check.status === 'fail');
|
|
137
|
+
const lines = [
|
|
138
|
+
...notifications.map(
|
|
139
|
+
(entry) =>
|
|
140
|
+
`${entry.event} ${entry.node}${entry.checks.length ? `: ${entry.checks.join(', ')}` : ''}`
|
|
141
|
+
),
|
|
142
|
+
...checks.map((check) => `${check.status} ${check.id} ${check.summary}`),
|
|
143
|
+
];
|
|
144
|
+
return {
|
|
145
|
+
exitCode: ok ? 0 : 1,
|
|
146
|
+
data: { version: 1, command: 'fleet-watch', ok, notifications, checks },
|
|
147
|
+
text: lines.join('\n') || 'Nothing changed.',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The watcher's object in the bucket, beside the fleet state. */
|
|
152
|
+
export const WATCH_OBJECT = 'watch.json';
|
|
153
|
+
|
|
154
|
+
export function bucketStore(bucket: Bucket | null): WatchStore | null {
|
|
155
|
+
if (!bucket) return null;
|
|
156
|
+
if (bucket.kind === 'directory') {
|
|
157
|
+
const path = join(bucket.path, WATCH_OBJECT);
|
|
158
|
+
return {
|
|
159
|
+
read: () => readFile(path, 'utf8').catch(() => null),
|
|
160
|
+
write: async (text) => {
|
|
161
|
+
await mkdir(dirname(path), { recursive: true });
|
|
162
|
+
await writeFile(path, text, { mode: 0o600 });
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const client = new Bun.S3Client({
|
|
167
|
+
bucket: bucket.bucket,
|
|
168
|
+
...(bucket.endpoint ? { endpoint: bucket.endpoint } : {}),
|
|
169
|
+
...(bucket.region ? { region: bucket.region } : {}),
|
|
170
|
+
...(bucket.accessKeyId && bucket.secretAccessKey
|
|
171
|
+
? {
|
|
172
|
+
accessKeyId: bucket.accessKeyId,
|
|
173
|
+
secretAccessKey: bucket.secretAccessKey,
|
|
174
|
+
}
|
|
175
|
+
: {}),
|
|
176
|
+
});
|
|
177
|
+
const file = client.file(`${bucket.prefix}/${WATCH_OBJECT}`);
|
|
178
|
+
return {
|
|
179
|
+
read: async () => ((await file.exists()) ? file.text() : null),
|
|
180
|
+
write: async (text) => {
|
|
181
|
+
await file.write(text, { type: 'application/json' });
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The services of a real run, on the manager. */
|
|
187
|
+
export function watchServices(
|
|
188
|
+
status: () => Promise<NodeReport[]>
|
|
189
|
+
): FleetWatchServices {
|
|
190
|
+
return {
|
|
191
|
+
notify: fleet.notify,
|
|
192
|
+
status,
|
|
193
|
+
store: bucketStore(connection().bucket),
|
|
194
|
+
secrets: secret,
|
|
195
|
+
send: fetch,
|
|
196
|
+
now: () => new Date(),
|
|
197
|
+
};
|
|
198
|
+
}
|
package/src/cli/fleet.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { addNode, nodeFromOptions, readRoster } from './roster';
|
|
|
14
14
|
import { executeTailnet } from './tailnet';
|
|
15
15
|
import { probeSource } from './probe-source';
|
|
16
16
|
import { executeRepairCommand } from './repair';
|
|
17
|
+
import { scheduleCheck } from '../schedule-check';
|
|
18
|
+
import { readHistory } from '../state/history';
|
|
17
19
|
|
|
18
20
|
export interface FleetServices {
|
|
19
21
|
run: Runner;
|
|
@@ -125,6 +127,8 @@ async function diagnose(
|
|
|
125
127
|
services.run
|
|
126
128
|
);
|
|
127
129
|
if (!options.local && settings.missingRoster) reports.push(missingRoster());
|
|
130
|
+
const schedule = scheduleCheck(readHistory(), new Date());
|
|
131
|
+
if (schedule) reports[0]?.checks.push(schedule);
|
|
128
132
|
const report = {
|
|
129
133
|
...makeReport('doctor', reports),
|
|
130
134
|
coverage: {
|
|
@@ -8,6 +8,9 @@ import { fleetSync, type FleetSyncOptions } from './fleet-sync';
|
|
|
8
8
|
import { syncNode, type NodeSyncOptions } from './node-sync';
|
|
9
9
|
import { fleetState, type FleetStateOptions } from './fleet-state';
|
|
10
10
|
import { fleetAccounts, type FleetAccountsOptions } from './fleet-accounts';
|
|
11
|
+
import { fleetReport, type FleetReportOptions } from './fleet-report';
|
|
12
|
+
import { fleetWatch, watchServices } from './fleet-watch';
|
|
13
|
+
import type { NodeReport } from '../types';
|
|
11
14
|
|
|
12
15
|
type Complete = (outcome: CommandOutcome) => void;
|
|
13
16
|
|
|
@@ -282,6 +285,52 @@ function registerState({ program, complete }: Registration) {
|
|
|
282
285
|
});
|
|
283
286
|
}
|
|
284
287
|
|
|
288
|
+
function registerReport({ program, complete }: Registration) {
|
|
289
|
+
program
|
|
290
|
+
.command('fleet-report')
|
|
291
|
+
.description(
|
|
292
|
+
'Report how reliably fleet-sync ran: success rate, duration, failing checks and time not ready'
|
|
293
|
+
)
|
|
294
|
+
.option('--since <duration>', 'How far back: 30d (default), 12h or 90m')
|
|
295
|
+
.action((_options, command: Command) => {
|
|
296
|
+
complete(fleetReport(command.optsWithGlobals<FleetReportOptions>()));
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function registerWatch({ program, defaults, complete }: Registration) {
|
|
301
|
+
program
|
|
302
|
+
.command('fleet-watch')
|
|
303
|
+
.description(
|
|
304
|
+
"Check the workers and notify the fleet's targets when one becomes unhealthy or recovers"
|
|
305
|
+
)
|
|
306
|
+
.option('--test', 'Send one test notification to every target and stop')
|
|
307
|
+
.addHelpText(
|
|
308
|
+
'after',
|
|
309
|
+
'\nRuns fleet-status --workers, compares it with the previous run kept in the bucket and notifies only on a change (and again every repeatHours while a worker stays unhealthy). Targets come from "notify" in fleet.json; their addresses are secrets. Every run stores a heartbeat in the bucket for an off-machine check.'
|
|
310
|
+
)
|
|
311
|
+
.action(async (_options, command: Command) => {
|
|
312
|
+
const options = command.optsWithGlobals<
|
|
313
|
+
CliOptions & { test?: boolean }
|
|
314
|
+
>();
|
|
315
|
+
const settings = await withSettings(defaults, options);
|
|
316
|
+
const status = async (): Promise<NodeReport[]> => {
|
|
317
|
+
const report = await executeFleet(
|
|
318
|
+
'health',
|
|
319
|
+
{ ...options, workers: true, managed: true },
|
|
320
|
+
settings
|
|
321
|
+
);
|
|
322
|
+
const data = report.data as { nodes?: NodeReport[] };
|
|
323
|
+
return data.nodes ?? [];
|
|
324
|
+
};
|
|
325
|
+
complete(
|
|
326
|
+
await fleetWatch(
|
|
327
|
+
{ ...(options.test ? { test: true } : {}) },
|
|
328
|
+
watchServices(status)
|
|
329
|
+
)
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
285
334
|
export function registerFleet(
|
|
286
335
|
program: Command,
|
|
287
336
|
defaults: Settings,
|
|
@@ -296,6 +345,8 @@ export function registerFleet(
|
|
|
296
345
|
);
|
|
297
346
|
registerSync(registration);
|
|
298
347
|
registerState(registration);
|
|
348
|
+
registerReport(registration);
|
|
349
|
+
registerWatch(registration);
|
|
299
350
|
registerRepair(registration);
|
|
300
351
|
registerNodes(registration);
|
|
301
352
|
registerAccounts(registration);
|
package/src/cli/registry.ts
CHANGED
|
@@ -50,6 +50,13 @@ export const COMMANDS: CommandSpec[] = [
|
|
|
50
50
|
'Read-only canonical worker readiness, dependencies, services and authentication; lists every remaining gap.',
|
|
51
51
|
['workers', 'readiness', 'health', 'canonical'],
|
|
52
52
|
],
|
|
53
|
+
[
|
|
54
|
+
'fleet-watch',
|
|
55
|
+
'Notify when a worker becomes unhealthy or recovers',
|
|
56
|
+
'Runs fleet-status --workers, compares it with the previous run kept in the bucket and notifies the targets fleet.json names under "notify" on a change only. Stores a heartbeat for an off-machine check. --test sends one test notification.',
|
|
57
|
+
['notify', 'alert', 'webhook', 'ntfy', 'heartbeat', 'watch', 'monitor'],
|
|
58
|
+
true,
|
|
59
|
+
],
|
|
53
60
|
[
|
|
54
61
|
'fleet-discover',
|
|
55
62
|
'Discover Tailscale computers',
|
|
@@ -90,6 +97,12 @@ export const COMMANDS: CommandSpec[] = [
|
|
|
90
97
|
['state', 'bucket', 'restore', 'recover', 'disposable'],
|
|
91
98
|
true,
|
|
92
99
|
],
|
|
100
|
+
[
|
|
101
|
+
'fleet-report',
|
|
102
|
+
'Report how reliably fleet-sync runs',
|
|
103
|
+
'Read-only: success rate, mean duration, the most frequent failing check IDs and how long each machine was not ready, from the history of fleet-sync runs kept in the fleet state. --since 30d (default), 12h or 90m.',
|
|
104
|
+
['history', 'reliability', 'report', 'failures', 'sync'],
|
|
105
|
+
],
|
|
93
106
|
[
|
|
94
107
|
'fleet-node-sync',
|
|
95
108
|
'Sync this node',
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** The most a scheduled job's log may hold; a longer one keeps its newest
|
|
5
|
+
* quarter.
|
|
6
|
+
*/
|
|
7
|
+
export const SCHEDULE_LOG_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Caps the schedule's own logs (`<label>.out.log`, `<label>.err.log`) at the
|
|
10
|
+
* start of every scheduled run. launchd and systemd append to them, so the
|
|
11
|
+
* newest lines are what is kept. Nothing else in the directory is touched.
|
|
12
|
+
*/
|
|
13
|
+
export function capScheduleLogs(
|
|
14
|
+
directory: string,
|
|
15
|
+
limit = SCHEDULE_LOG_BYTES
|
|
16
|
+
): void {
|
|
17
|
+
let names: string[];
|
|
18
|
+
try {
|
|
19
|
+
names = readdirSync(directory);
|
|
20
|
+
} catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
if (!/^[a-z0-9-]+\.(out|err)\.log$/.test(name)) continue;
|
|
25
|
+
const path = join(directory, name);
|
|
26
|
+
try {
|
|
27
|
+
const stat = statSync(path);
|
|
28
|
+
if (!stat.isFile() || stat.size <= limit) continue;
|
|
29
|
+
const kept = readFileSync(path).subarray(stat.size - limit / 4);
|
|
30
|
+
const start = kept.indexOf(10) + 1;
|
|
31
|
+
writeFileSync(path, kept.subarray(start), { mode: 0o600 });
|
|
32
|
+
} catch {
|
|
33
|
+
// A log that cannot be capped is capped by the next run.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/clock.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import policy from '../ansible/ubuntu/policy.json';
|
|
2
|
+
import type { HealthSample } from './machine-health';
|
|
3
|
+
import type { Check } from './types';
|
|
4
|
+
|
|
5
|
+
const { warnOffsetSeconds, failOffsetSeconds } = policy.clock;
|
|
6
|
+
|
|
7
|
+
/** A skewed clock breaks TLS, token validation, apt signatures and Vault
|
|
8
|
+
* logins with errors that never mention time. The clock passes when it is
|
|
9
|
+
* synchronised to a time source and, where a reference time was measured,
|
|
10
|
+
* within the offset floors of `policy.json`.
|
|
11
|
+
*
|
|
12
|
+
* `offsetSeconds` is the difference to the reference (the manager's clock),
|
|
13
|
+
* when one was measured.
|
|
14
|
+
*/
|
|
15
|
+
export function clockCheck(
|
|
16
|
+
id: string,
|
|
17
|
+
sample: Pick<HealthSample, 'clockSynced' | 'timeService'>,
|
|
18
|
+
offsetSeconds?: number
|
|
19
|
+
): Check {
|
|
20
|
+
const offset =
|
|
21
|
+
offsetSeconds === undefined ? undefined : Math.abs(offsetSeconds);
|
|
22
|
+
const measured =
|
|
23
|
+
offset === undefined ? '' : `, ${offset.toFixed(1)} s from the manager`;
|
|
24
|
+
const remedy = clockRemedy(sample.timeService);
|
|
25
|
+
if (sample.clockSynced === undefined && offset === undefined)
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
status: 'skip',
|
|
29
|
+
summary: 'CLOCK_UNOBSERVED: the clock synchronisation could not be read.',
|
|
30
|
+
};
|
|
31
|
+
if (offset !== undefined && offset > failOffsetSeconds)
|
|
32
|
+
return { id, status: 'fail', summary: `CLOCK_SKEWED${measured}`, remedy };
|
|
33
|
+
if (sample.clockSynced === 0 && sample.timeService === 0)
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
status: 'fail',
|
|
37
|
+
summary: `CLOCK_NOT_SYNCHRONISED: no time service is enabled${measured}`,
|
|
38
|
+
remedy,
|
|
39
|
+
};
|
|
40
|
+
if (sample.clockSynced === 0)
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
status: 'warn',
|
|
44
|
+
summary: `CLOCK_NOT_SYNCHRONISED${measured}`,
|
|
45
|
+
remedy,
|
|
46
|
+
};
|
|
47
|
+
if (offset !== undefined && offset > warnOffsetSeconds)
|
|
48
|
+
return { id, status: 'warn', summary: `CLOCK_DRIFTING${measured}`, remedy };
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
status: 'pass',
|
|
52
|
+
summary: `The clock is synchronised to a time source${measured}.`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clockRemedy(timeService: number | undefined): { action: string } {
|
|
57
|
+
return {
|
|
58
|
+
action:
|
|
59
|
+
timeService === 0
|
|
60
|
+
? 'Enable the time service: apply the approved fleet-sync revision, which keeps chrony or systemd-timesyncd enabled on workers (timedatectl set-ntp true is the same setting).'
|
|
61
|
+
: 'Check that the time service reaches a time server (timedatectl timesync-status) and that UDP port 123 is not blocked.',
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/definition.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
parseWorkerPolicy,
|
|
3
|
+
toolProfiles,
|
|
4
|
+
type WorkerPolicy,
|
|
5
|
+
} from './worker-policy';
|
|
6
|
+
import type { ToolProfile } from '../bootstrap/worker-tools';
|
|
2
7
|
import { readFileSync } from 'node:fs';
|
|
3
8
|
import { FleetUsageError } from './errors';
|
|
4
9
|
import { parseConfig, record } from './config';
|
|
@@ -11,11 +16,14 @@ import {
|
|
|
11
16
|
type IdeId,
|
|
12
17
|
type T3Definition,
|
|
13
18
|
} from './ide/definition';
|
|
19
|
+
import { parseNotify, type NotifyConfig } from './notify';
|
|
14
20
|
|
|
15
21
|
export interface MachineDefinition {
|
|
16
22
|
id: string;
|
|
17
23
|
ssh: string;
|
|
18
24
|
os: 'linux' | 'macOS' | 'windows';
|
|
25
|
+
/** Tool profiles this worker adds to the fleet-wide ones. */
|
|
26
|
+
toolProfiles?: ToolProfile[];
|
|
19
27
|
network?: {
|
|
20
28
|
tailscaleName: string;
|
|
21
29
|
user: string;
|
|
@@ -55,6 +63,8 @@ export interface FleetDefinition {
|
|
|
55
63
|
managerAccount: string;
|
|
56
64
|
/** Read-only Beszel account visitors are signed in as. */
|
|
57
65
|
viewerAccount: string;
|
|
66
|
+
/** Scheduled off-machine backups fleet-sync sets on the hub. */
|
|
67
|
+
backups?: HubBackups;
|
|
58
68
|
};
|
|
59
69
|
};
|
|
60
70
|
worker?: WorkerPolicy;
|
|
@@ -62,6 +72,12 @@ export interface FleetDefinition {
|
|
|
62
72
|
ides?: IdeId[];
|
|
63
73
|
/** What every machine's T3 holds beyond its provider instances. */
|
|
64
74
|
t3?: T3Definition;
|
|
75
|
+
/** The Git identity and GitHub credential helper every node, manager
|
|
76
|
+
* included, commits and pushes with.
|
|
77
|
+
*/
|
|
78
|
+
git?: GitIdentity;
|
|
79
|
+
/** Where fleet-watch sends notifications; absent, nobody is notified. */
|
|
80
|
+
notify?: NotifyConfig;
|
|
65
81
|
ubuntu: { release: '26.04'; architectures: ['amd64', 'arm64'] };
|
|
66
82
|
}
|
|
67
83
|
/** How provider accounts are watched and chosen (src/accounts/). */
|
|
@@ -80,6 +96,29 @@ export interface AccountSelection {
|
|
|
80
96
|
*/
|
|
81
97
|
order?: string[];
|
|
82
98
|
}
|
|
99
|
+
export interface HubBackups {
|
|
100
|
+
/** Cron expression of the hub's backup schedule. */
|
|
101
|
+
schedule: string;
|
|
102
|
+
/** How many scheduled backups the hub keeps. */
|
|
103
|
+
keep: number;
|
|
104
|
+
/** S3-compatible storage off the manager; its keys are secrets. */
|
|
105
|
+
storage: {
|
|
106
|
+
bucket: string;
|
|
107
|
+
endpoint: string;
|
|
108
|
+
region: string;
|
|
109
|
+
forcePathStyle?: boolean;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export interface GitIdentity {
|
|
113
|
+
/** `user.name` of every commit. */
|
|
114
|
+
name: string;
|
|
115
|
+
/** `user.email` of every commit. */
|
|
116
|
+
email: string;
|
|
117
|
+
/** A repository each node must reach through git with its own
|
|
118
|
+
* credentials, proving it can push (`worker:git-push-auth`).
|
|
119
|
+
*/
|
|
120
|
+
verifyRepository?: string;
|
|
121
|
+
}
|
|
83
122
|
const ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
84
123
|
const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
|
|
85
124
|
function fail(): never {
|
|
@@ -116,7 +155,10 @@ function validPort(value: unknown): boolean {
|
|
|
116
155
|
);
|
|
117
156
|
}
|
|
118
157
|
function worker(value: unknown): MachineDefinition {
|
|
119
|
-
if (
|
|
158
|
+
if (
|
|
159
|
+
!record(value) ||
|
|
160
|
+
!keys(value, ['id', 'ssh', 'os', 'network', 'toolProfiles'])
|
|
161
|
+
)
|
|
120
162
|
return fail();
|
|
121
163
|
if (typeof value['id'] !== 'string' || !ID.test(value['id'])) return fail();
|
|
122
164
|
if (typeof value['ssh'] !== 'string' || !HOST.test(value['ssh']))
|
|
@@ -128,6 +170,9 @@ function worker(value: unknown): MachineDefinition {
|
|
|
128
170
|
ssh: value['ssh'],
|
|
129
171
|
os: 'linux',
|
|
130
172
|
...(value['network'] ? { network: network(value['network']) } : {}),
|
|
173
|
+
...(value['toolProfiles'] === undefined
|
|
174
|
+
? {}
|
|
175
|
+
: { toolProfiles: toolProfiles(value['toolProfiles']) ?? fail() }),
|
|
131
176
|
};
|
|
132
177
|
}
|
|
133
178
|
function manager(value: unknown): ManagerDefinition {
|
|
@@ -176,6 +221,8 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
176
221
|
'worker',
|
|
177
222
|
'ides',
|
|
178
223
|
't3',
|
|
224
|
+
'git',
|
|
225
|
+
'notify',
|
|
179
226
|
])
|
|
180
227
|
)
|
|
181
228
|
return fail();
|
|
@@ -194,12 +241,25 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
194
241
|
workers,
|
|
195
242
|
services: services(value['services']),
|
|
196
243
|
ubuntu: baseline(value['ubuntu']),
|
|
244
|
+
...optionalSections(value),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** The sections a fleet may leave out, kept only when named: an earlier
|
|
248
|
+
* revision must still read the copy.
|
|
249
|
+
*/
|
|
250
|
+
function optionalSections(
|
|
251
|
+
value: Record<string, unknown>
|
|
252
|
+
): Pick<FleetDefinition, 'worker' | 'ides' | 't3' | 'git' | 'notify'> {
|
|
253
|
+
return {
|
|
197
254
|
...(value['worker'] === undefined
|
|
198
255
|
? {}
|
|
199
256
|
: { worker: parseWorkerPolicy(value['worker']) }),
|
|
200
|
-
// Kept only when named: an earlier revision must still read the copy.
|
|
201
257
|
...(value['ides'] === undefined ? {} : { ides: parseIdes(value['ides']) }),
|
|
202
258
|
...(value['t3'] === undefined ? {} : { t3: parseT3(value['t3']) }),
|
|
259
|
+
...(value['git'] === undefined ? {} : { git: gitIdentity(value['git']) }),
|
|
260
|
+
...(value['notify'] === undefined
|
|
261
|
+
? {}
|
|
262
|
+
: { notify: parseNotify(value['notify']) ?? fail() }),
|
|
203
263
|
};
|
|
204
264
|
}
|
|
205
265
|
/** Singleton services name no host: they always run on the manager. */
|
|
@@ -271,19 +331,96 @@ function accountSelection(entry: unknown): AccountSelection {
|
|
|
271
331
|
function monitoringService(
|
|
272
332
|
value: unknown
|
|
273
333
|
): FleetDefinition['services']['monitoring'] {
|
|
274
|
-
const fields = [
|
|
334
|
+
const fields = [
|
|
335
|
+
'hubPort',
|
|
336
|
+
'publicUrl',
|
|
337
|
+
'managerAccount',
|
|
338
|
+
'viewerAccount',
|
|
339
|
+
'backups',
|
|
340
|
+
];
|
|
275
341
|
if (!record(value) || !keys(value, fields) || value['hubPort'] !== 8443)
|
|
276
342
|
return fail();
|
|
277
343
|
const { publicUrl, managerAccount, viewerAccount } = value;
|
|
278
344
|
if (!origin(publicUrl) || !account(managerAccount) || !account(viewerAccount))
|
|
279
345
|
return fail();
|
|
280
|
-
return {
|
|
346
|
+
return {
|
|
347
|
+
hubPort: 8443,
|
|
348
|
+
publicUrl,
|
|
349
|
+
managerAccount,
|
|
350
|
+
viewerAccount,
|
|
351
|
+
...(value['backups'] === undefined
|
|
352
|
+
? {}
|
|
353
|
+
: { backups: hubBackups(value['backups']) }),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const text = (value: unknown, pattern: RegExp): value is string =>
|
|
357
|
+
typeof value === 'string' && pattern.test(value);
|
|
358
|
+
function hubBackups(value: unknown): HubBackups {
|
|
359
|
+
if (!record(value) || !keys(value, ['schedule', 'keep', 'storage']))
|
|
360
|
+
return fail();
|
|
361
|
+
const { schedule, keep } = value;
|
|
362
|
+
if (
|
|
363
|
+
!text(schedule, /^[0-9*/,-]+( [0-9*/,-]+){4}$/) ||
|
|
364
|
+
!Number.isInteger(keep) ||
|
|
365
|
+
Number(keep) < 1 ||
|
|
366
|
+
Number(keep) > 365
|
|
367
|
+
)
|
|
368
|
+
return fail();
|
|
369
|
+
return {
|
|
370
|
+
schedule,
|
|
371
|
+
keep: Number(keep),
|
|
372
|
+
storage: backupStorage(value['storage']),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function backupStorage(value: unknown): HubBackups['storage'] {
|
|
376
|
+
if (
|
|
377
|
+
!record(value) ||
|
|
378
|
+
!keys(value, ['bucket', 'endpoint', 'region', 'forcePathStyle'])
|
|
379
|
+
)
|
|
380
|
+
return fail();
|
|
381
|
+
const { bucket, endpoint, region, forcePathStyle } = value;
|
|
382
|
+
if (
|
|
383
|
+
!text(bucket, /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/) ||
|
|
384
|
+
!text(endpoint, /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/) ||
|
|
385
|
+
!text(region, /^[a-z0-9-]{1,32}$/) ||
|
|
386
|
+
(forcePathStyle !== undefined && typeof forcePathStyle !== 'boolean')
|
|
387
|
+
)
|
|
388
|
+
return fail();
|
|
389
|
+
return {
|
|
390
|
+
bucket,
|
|
391
|
+
endpoint,
|
|
392
|
+
region,
|
|
393
|
+
...(forcePathStyle === undefined ? {} : { forcePathStyle }),
|
|
394
|
+
};
|
|
281
395
|
}
|
|
282
396
|
const origin = (value: unknown): value is string =>
|
|
283
397
|
typeof value === 'string' && /^https:\/\/[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
284
398
|
const account = (value: unknown): value is string =>
|
|
285
399
|
typeof value === 'string' &&
|
|
286
400
|
/^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
401
|
+
function gitIdentity(value: unknown): GitIdentity {
|
|
402
|
+
if (!record(value) || !keys(value, ['name', 'email', 'verifyRepository']))
|
|
403
|
+
return fail();
|
|
404
|
+
const { name, email, verifyRepository } = value;
|
|
405
|
+
// Written into a quoted Git configuration value: no quote, backslash or
|
|
406
|
+
// line break.
|
|
407
|
+
if (typeof name !== 'string' || !/^[^"\\\n\r]{1,100}$/.test(name))
|
|
408
|
+
return fail();
|
|
409
|
+
if (!account(email)) return fail();
|
|
410
|
+
if (
|
|
411
|
+
verifyRepository !== undefined &&
|
|
412
|
+
(typeof verifyRepository !== 'string' ||
|
|
413
|
+
!/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(
|
|
414
|
+
verifyRepository
|
|
415
|
+
))
|
|
416
|
+
)
|
|
417
|
+
return fail();
|
|
418
|
+
return {
|
|
419
|
+
name,
|
|
420
|
+
email,
|
|
421
|
+
...(verifyRepository === undefined ? {} : { verifyRepository }),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
287
424
|
function secrets(value: unknown): SecretsConfig {
|
|
288
425
|
return parseSecretsConfig(value) ?? fail();
|
|
289
426
|
}
|