@crvouga/dev-fleet 0.3.0 → 0.5.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 +25 -17
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/policy.json +12 -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/platforms.json +111 -0
- package/ansible/ubuntu/authorize-sudo.sh +7 -16
- package/ansible/ubuntu/baseline.yml +6 -28
- package/ansible/ubuntu/disk-gc.py +211 -7
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +68 -4
- package/ansible/ubuntu/t3-desktop.yml +2 -2
- package/ansible/ubuntu/tailscale.yml +20 -9
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/dpkg.yml +46 -0
- package/ansible/ubuntu/tasks/install.yml +38 -0
- package/ansible/ubuntu/tasks/packages.yml +4 -55
- package/ansible/ubuntu/tasks/release.yml +25 -0
- package/ansible/ubuntu/tasks/resources.yml +7 -6
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/ansible/ubuntu/tasks/snapshot.yml +50 -0
- package/ansible/ubuntu/tasks/unattended-upgrades.yml +24 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +49 -45
- 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/consumer-setup.md +32 -30
- package/docs/declared.md +6 -5
- package/docs/machine-setup.md +22 -12
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +377 -0
- package/docs/state.md +13 -7
- package/docs/support-matrix.md +36 -0
- package/docs/ubuntu-sync.md +16 -13
- package/docs/worker-contract.md +28 -1
- 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 +526 -0
- package/schema/fleet.schema.json +185 -4
- 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 +4 -2
- 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 +160 -12
- package/src/git-identity.ts +160 -0
- package/src/machine/platform.ts +48 -28
- package/src/machine/support-matrix.ts +76 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +27 -9
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/network-definition.ts +1 -1
- package/src/node-converge-linux.ts +16 -5
- package/src/node-converge.ts +1 -1
- package/src/notify.ts +239 -0
- package/src/platform/debian-version.ts +62 -0
- package/src/platform/ubuntu-audit.ts +11 -4
- package/src/platform/ubuntu-release.ts +41 -0
- package/src/remote-bootstrap.ts +89 -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 +68 -10
- 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,15 @@ import {
|
|
|
11
16
|
type IdeId,
|
|
12
17
|
type T3Definition,
|
|
13
18
|
} from './ide/definition';
|
|
19
|
+
import { parseNotify, type NotifyConfig } from './notify';
|
|
20
|
+
import { ubuntuRelease } from './platform/ubuntu-release';
|
|
14
21
|
|
|
15
22
|
export interface MachineDefinition {
|
|
16
23
|
id: string;
|
|
17
24
|
ssh: string;
|
|
18
|
-
os: 'linux'
|
|
25
|
+
os: 'linux';
|
|
26
|
+
/** Tool profiles this worker adds to the fleet-wide ones. */
|
|
27
|
+
toolProfiles?: ToolProfile[];
|
|
19
28
|
network?: {
|
|
20
29
|
tailscaleName: string;
|
|
21
30
|
user: string;
|
|
@@ -55,6 +64,8 @@ export interface FleetDefinition {
|
|
|
55
64
|
managerAccount: string;
|
|
56
65
|
/** Read-only Beszel account visitors are signed in as. */
|
|
57
66
|
viewerAccount: string;
|
|
67
|
+
/** Scheduled off-machine backups fleet-sync sets on the hub. */
|
|
68
|
+
backups?: HubBackups;
|
|
58
69
|
};
|
|
59
70
|
};
|
|
60
71
|
worker?: WorkerPolicy;
|
|
@@ -62,7 +73,15 @@ export interface FleetDefinition {
|
|
|
62
73
|
ides?: IdeId[];
|
|
63
74
|
/** What every machine's T3 holds beyond its provider instances. */
|
|
64
75
|
t3?: T3Definition;
|
|
65
|
-
|
|
76
|
+
/** The Git identity and GitHub credential helper every node, manager
|
|
77
|
+
* included, commits and pushes with.
|
|
78
|
+
*/
|
|
79
|
+
git?: GitIdentity;
|
|
80
|
+
/** Where fleet-watch sends notifications; absent, nobody is notified. */
|
|
81
|
+
notify?: NotifyConfig;
|
|
82
|
+
/** An Ubuntu release ansible/ubuntu/policy.json accepts, with exactly the
|
|
83
|
+
* architectures the policy names for it. */
|
|
84
|
+
ubuntu: { release: string; architectures: string[] };
|
|
66
85
|
}
|
|
67
86
|
/** How provider accounts are watched and chosen (src/accounts/). */
|
|
68
87
|
export interface AccountPolicy {
|
|
@@ -80,6 +99,29 @@ export interface AccountSelection {
|
|
|
80
99
|
*/
|
|
81
100
|
order?: string[];
|
|
82
101
|
}
|
|
102
|
+
export interface HubBackups {
|
|
103
|
+
/** Cron expression of the hub's backup schedule. */
|
|
104
|
+
schedule: string;
|
|
105
|
+
/** How many scheduled backups the hub keeps. */
|
|
106
|
+
keep: number;
|
|
107
|
+
/** S3-compatible storage off the manager; its keys are secrets. */
|
|
108
|
+
storage: {
|
|
109
|
+
bucket: string;
|
|
110
|
+
endpoint: string;
|
|
111
|
+
region: string;
|
|
112
|
+
forcePathStyle?: boolean;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export interface GitIdentity {
|
|
116
|
+
/** `user.name` of every commit. */
|
|
117
|
+
name: string;
|
|
118
|
+
/** `user.email` of every commit. */
|
|
119
|
+
email: string;
|
|
120
|
+
/** A repository each node must reach through git with its own
|
|
121
|
+
* credentials, proving it can push (`worker:git-push-auth`).
|
|
122
|
+
*/
|
|
123
|
+
verifyRepository?: string;
|
|
124
|
+
}
|
|
83
125
|
const ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
84
126
|
const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
|
|
85
127
|
function fail(): never {
|
|
@@ -116,7 +158,10 @@ function validPort(value: unknown): boolean {
|
|
|
116
158
|
);
|
|
117
159
|
}
|
|
118
160
|
function worker(value: unknown): MachineDefinition {
|
|
119
|
-
if (
|
|
161
|
+
if (
|
|
162
|
+
!record(value) ||
|
|
163
|
+
!keys(value, ['id', 'ssh', 'os', 'network', 'toolProfiles'])
|
|
164
|
+
)
|
|
120
165
|
return fail();
|
|
121
166
|
if (typeof value['id'] !== 'string' || !ID.test(value['id'])) return fail();
|
|
122
167
|
if (typeof value['ssh'] !== 'string' || !HOST.test(value['ssh']))
|
|
@@ -128,6 +173,9 @@ function worker(value: unknown): MachineDefinition {
|
|
|
128
173
|
ssh: value['ssh'],
|
|
129
174
|
os: 'linux',
|
|
130
175
|
...(value['network'] ? { network: network(value['network']) } : {}),
|
|
176
|
+
...(value['toolProfiles'] === undefined
|
|
177
|
+
? {}
|
|
178
|
+
: { toolProfiles: toolProfiles(value['toolProfiles']) ?? fail() }),
|
|
131
179
|
};
|
|
132
180
|
}
|
|
133
181
|
function manager(value: unknown): ManagerDefinition {
|
|
@@ -176,6 +224,8 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
176
224
|
'worker',
|
|
177
225
|
'ides',
|
|
178
226
|
't3',
|
|
227
|
+
'git',
|
|
228
|
+
'notify',
|
|
179
229
|
])
|
|
180
230
|
)
|
|
181
231
|
return fail();
|
|
@@ -194,12 +244,25 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
194
244
|
workers,
|
|
195
245
|
services: services(value['services']),
|
|
196
246
|
ubuntu: baseline(value['ubuntu']),
|
|
247
|
+
...optionalSections(value),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/** The sections a fleet may leave out, kept only when named: an earlier
|
|
251
|
+
* revision must still read the copy.
|
|
252
|
+
*/
|
|
253
|
+
function optionalSections(
|
|
254
|
+
value: Record<string, unknown>
|
|
255
|
+
): Pick<FleetDefinition, 'worker' | 'ides' | 't3' | 'git' | 'notify'> {
|
|
256
|
+
return {
|
|
197
257
|
...(value['worker'] === undefined
|
|
198
258
|
? {}
|
|
199
259
|
: { worker: parseWorkerPolicy(value['worker']) }),
|
|
200
|
-
// Kept only when named: an earlier revision must still read the copy.
|
|
201
260
|
...(value['ides'] === undefined ? {} : { ides: parseIdes(value['ides']) }),
|
|
202
261
|
...(value['t3'] === undefined ? {} : { t3: parseT3(value['t3']) }),
|
|
262
|
+
...(value['git'] === undefined ? {} : { git: gitIdentity(value['git']) }),
|
|
263
|
+
...(value['notify'] === undefined
|
|
264
|
+
? {}
|
|
265
|
+
: { notify: parseNotify(value['notify']) ?? fail() }),
|
|
203
266
|
};
|
|
204
267
|
}
|
|
205
268
|
/** Singleton services name no host: they always run on the manager. */
|
|
@@ -271,19 +334,96 @@ function accountSelection(entry: unknown): AccountSelection {
|
|
|
271
334
|
function monitoringService(
|
|
272
335
|
value: unknown
|
|
273
336
|
): FleetDefinition['services']['monitoring'] {
|
|
274
|
-
const fields = [
|
|
337
|
+
const fields = [
|
|
338
|
+
'hubPort',
|
|
339
|
+
'publicUrl',
|
|
340
|
+
'managerAccount',
|
|
341
|
+
'viewerAccount',
|
|
342
|
+
'backups',
|
|
343
|
+
];
|
|
275
344
|
if (!record(value) || !keys(value, fields) || value['hubPort'] !== 8443)
|
|
276
345
|
return fail();
|
|
277
346
|
const { publicUrl, managerAccount, viewerAccount } = value;
|
|
278
347
|
if (!origin(publicUrl) || !account(managerAccount) || !account(viewerAccount))
|
|
279
348
|
return fail();
|
|
280
|
-
return {
|
|
349
|
+
return {
|
|
350
|
+
hubPort: 8443,
|
|
351
|
+
publicUrl,
|
|
352
|
+
managerAccount,
|
|
353
|
+
viewerAccount,
|
|
354
|
+
...(value['backups'] === undefined
|
|
355
|
+
? {}
|
|
356
|
+
: { backups: hubBackups(value['backups']) }),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const text = (value: unknown, pattern: RegExp): value is string =>
|
|
360
|
+
typeof value === 'string' && pattern.test(value);
|
|
361
|
+
function hubBackups(value: unknown): HubBackups {
|
|
362
|
+
if (!record(value) || !keys(value, ['schedule', 'keep', 'storage']))
|
|
363
|
+
return fail();
|
|
364
|
+
const { schedule, keep } = value;
|
|
365
|
+
if (
|
|
366
|
+
!text(schedule, /^[0-9*/,-]+( [0-9*/,-]+){4}$/) ||
|
|
367
|
+
!Number.isInteger(keep) ||
|
|
368
|
+
Number(keep) < 1 ||
|
|
369
|
+
Number(keep) > 365
|
|
370
|
+
)
|
|
371
|
+
return fail();
|
|
372
|
+
return {
|
|
373
|
+
schedule,
|
|
374
|
+
keep: Number(keep),
|
|
375
|
+
storage: backupStorage(value['storage']),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function backupStorage(value: unknown): HubBackups['storage'] {
|
|
379
|
+
if (
|
|
380
|
+
!record(value) ||
|
|
381
|
+
!keys(value, ['bucket', 'endpoint', 'region', 'forcePathStyle'])
|
|
382
|
+
)
|
|
383
|
+
return fail();
|
|
384
|
+
const { bucket, endpoint, region, forcePathStyle } = value;
|
|
385
|
+
if (
|
|
386
|
+
!text(bucket, /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/) ||
|
|
387
|
+
!text(endpoint, /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/) ||
|
|
388
|
+
!text(region, /^[a-z0-9-]{1,32}$/) ||
|
|
389
|
+
(forcePathStyle !== undefined && typeof forcePathStyle !== 'boolean')
|
|
390
|
+
)
|
|
391
|
+
return fail();
|
|
392
|
+
return {
|
|
393
|
+
bucket,
|
|
394
|
+
endpoint,
|
|
395
|
+
region,
|
|
396
|
+
...(forcePathStyle === undefined ? {} : { forcePathStyle }),
|
|
397
|
+
};
|
|
281
398
|
}
|
|
282
399
|
const origin = (value: unknown): value is string =>
|
|
283
400
|
typeof value === 'string' && /^https:\/\/[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
284
401
|
const account = (value: unknown): value is string =>
|
|
285
402
|
typeof value === 'string' &&
|
|
286
403
|
/^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/.test(value);
|
|
404
|
+
function gitIdentity(value: unknown): GitIdentity {
|
|
405
|
+
if (!record(value) || !keys(value, ['name', 'email', 'verifyRepository']))
|
|
406
|
+
return fail();
|
|
407
|
+
const { name, email, verifyRepository } = value;
|
|
408
|
+
// Written into a quoted Git configuration value: no quote, backslash or
|
|
409
|
+
// line break.
|
|
410
|
+
if (typeof name !== 'string' || !/^[^"\\\n\r]{1,100}$/.test(name))
|
|
411
|
+
return fail();
|
|
412
|
+
if (!account(email)) return fail();
|
|
413
|
+
if (
|
|
414
|
+
verifyRepository !== undefined &&
|
|
415
|
+
(typeof verifyRepository !== 'string' ||
|
|
416
|
+
!/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(
|
|
417
|
+
verifyRepository
|
|
418
|
+
))
|
|
419
|
+
)
|
|
420
|
+
return fail();
|
|
421
|
+
return {
|
|
422
|
+
name,
|
|
423
|
+
email,
|
|
424
|
+
...(verifyRepository === undefined ? {} : { verifyRepository }),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
287
427
|
function secrets(value: unknown): SecretsConfig {
|
|
288
428
|
return parseSecretsConfig(value) ?? fail();
|
|
289
429
|
}
|
|
@@ -312,14 +452,22 @@ function source(value: unknown): FleetDefinition['source'] {
|
|
|
312
452
|
return { repository: value['repository'] };
|
|
313
453
|
}
|
|
314
454
|
function baseline(ubuntu: unknown): FleetDefinition['ubuntu'] {
|
|
455
|
+
if (!record(ubuntu) || !keys(ubuntu, ['release', 'architectures']))
|
|
456
|
+
return fail();
|
|
457
|
+
const accepted =
|
|
458
|
+
typeof ubuntu['release'] === 'string'
|
|
459
|
+
? ubuntuRelease(ubuntu['release'])
|
|
460
|
+
: undefined;
|
|
315
461
|
if (
|
|
316
|
-
!
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
JSON.stringify(ubuntu['architectures']) !== '["amd64","arm64"]'
|
|
462
|
+
!accepted ||
|
|
463
|
+
JSON.stringify(ubuntu['architectures']) !==
|
|
464
|
+
JSON.stringify(accepted.architectures)
|
|
320
465
|
)
|
|
321
466
|
return fail();
|
|
322
|
-
return
|
|
467
|
+
return {
|
|
468
|
+
release: accepted.release,
|
|
469
|
+
architectures: [...accepted.architectures],
|
|
470
|
+
};
|
|
323
471
|
}
|
|
324
472
|
|
|
325
473
|
/** The configuration as every machine keeps it at
|