@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
package/src/manager-sync.ts
CHANGED
|
@@ -6,16 +6,19 @@ import {
|
|
|
6
6
|
type NodeReport,
|
|
7
7
|
type Runner,
|
|
8
8
|
} from './types';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
discoverTailnet,
|
|
11
|
+
keyExpiryCheck,
|
|
12
|
+
type TailnetComputer,
|
|
13
|
+
} from './tailscale';
|
|
10
14
|
import { matchComputers } from './tailnet-matching';
|
|
11
15
|
import { sshArgs, sshFailure } from './ssh';
|
|
12
16
|
import type { FleetDefinition, MachineDefinition } from './definition';
|
|
13
17
|
import { publicHubUrl } from './monitoring/beszel';
|
|
14
18
|
import {
|
|
15
|
-
|
|
19
|
+
evaluateEnvironment,
|
|
16
20
|
evaluatePlatform,
|
|
17
21
|
PLATFORM_PROBE,
|
|
18
|
-
type MachinePlatform,
|
|
19
22
|
} from './machine/platform';
|
|
20
23
|
|
|
21
24
|
export interface ManagerNode extends NodeReport {
|
|
@@ -47,14 +50,14 @@ export type DeployNode = (
|
|
|
47
50
|
/** Minimal read-only POSIX inspection requires no workspace, Node, Bun or Git. */
|
|
48
51
|
export async function inspectBootstrap(
|
|
49
52
|
node: FleetNode,
|
|
50
|
-
run: Runner
|
|
51
|
-
os: MachinePlatform = 'linux'
|
|
53
|
+
run: Runner
|
|
52
54
|
): Promise<Check[]> {
|
|
53
55
|
const result = await run([...sshArgs(node), PLATFORM_PROBE], {
|
|
54
56
|
timeoutMs: 20_000,
|
|
55
57
|
});
|
|
56
58
|
if (!result.ok) return [sshFailure(result)];
|
|
57
|
-
const baseline = evaluatePlatform(
|
|
59
|
+
const baseline = evaluatePlatform(result.stdout);
|
|
60
|
+
const environment = evaluateEnvironment(result.stdout);
|
|
58
61
|
return [
|
|
59
62
|
{
|
|
60
63
|
id: 'ssh:fresh-session',
|
|
@@ -71,6 +74,15 @@ export async function inspectBootstrap(
|
|
|
71
74
|
'Review the release/architecture migration in source. No operating-system upgrade or downgrade is performed.',
|
|
72
75
|
},
|
|
73
76
|
},
|
|
77
|
+
{
|
|
78
|
+
id: environment.id,
|
|
79
|
+
status: environment.supported ? 'pass' : 'fail',
|
|
80
|
+
summary: environment.summary,
|
|
81
|
+
remedy: {
|
|
82
|
+
action:
|
|
83
|
+
'Run the worker on a physical or virtual machine of its own; docs/support-matrix.md lists what is supported.',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
74
86
|
];
|
|
75
87
|
}
|
|
76
88
|
|
|
@@ -102,7 +114,12 @@ export async function synchronizeManager(
|
|
|
102
114
|
ok: false,
|
|
103
115
|
applied: false,
|
|
104
116
|
desiredRevision: input.revision,
|
|
105
|
-
checks:
|
|
117
|
+
checks: [
|
|
118
|
+
...tailnet.checks,
|
|
119
|
+
...(tailnet.computers.some((peer) => peer.self)
|
|
120
|
+
? [keyExpiryCheck(tailnet.computers.find((peer) => peer.self))]
|
|
121
|
+
: []),
|
|
122
|
+
],
|
|
106
123
|
// Printed for people: the stable public name, never a tailnet address.
|
|
107
124
|
dashboardUrl: publicHubUrl(),
|
|
108
125
|
nodes: [],
|
|
@@ -115,7 +132,7 @@ export async function synchronizeManager(
|
|
|
115
132
|
// worker is then verified against.
|
|
116
133
|
if (managerRun) await managerRun;
|
|
117
134
|
const workers = await Promise.all(
|
|
118
|
-
input.definition.workers.
|
|
135
|
+
input.definition.workers.map((machine) => {
|
|
119
136
|
input.progress?.(
|
|
120
137
|
machine.id,
|
|
121
138
|
'Inspecting enrolled identity and fresh SSH access'
|
|
@@ -320,8 +337,9 @@ async function inspectManagerNode(
|
|
|
320
337
|
},
|
|
321
338
|
});
|
|
322
339
|
else {
|
|
323
|
-
checks.push(...(await inspectBootstrap(node, run
|
|
340
|
+
checks.push(...(await inspectBootstrap(node, run)));
|
|
324
341
|
checks.push(...identityChecks(node, peer));
|
|
342
|
+
if (peer) checks.push(keyExpiryCheck(peer));
|
|
325
343
|
}
|
|
326
344
|
if (!peer || !peer.online)
|
|
327
345
|
checks.push({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { record } from '../config';
|
|
2
|
+
import type { HubBackups } from '../definition';
|
|
2
3
|
import type { Check } from '../types';
|
|
3
4
|
import {
|
|
4
5
|
LOCAL_HUB,
|
|
@@ -10,6 +11,10 @@ import {
|
|
|
10
11
|
|
|
11
12
|
/** A scheduled backup older than this means the schedule is not running. */
|
|
12
13
|
export const BACKUP_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
|
14
|
+
/** Declared backups that have not reached off-machine storage for this long
|
|
15
|
+
* fail: the grace covers a fresh hub and a storage outage of a few days.
|
|
16
|
+
*/
|
|
17
|
+
export const BACKUP_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
18
|
|
|
14
19
|
function backupCheck(
|
|
15
20
|
status: Check['status'],
|
|
@@ -24,21 +29,26 @@ function backupCheck(
|
|
|
24
29
|
};
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
const
|
|
28
|
-
'
|
|
32
|
+
const UNDECLARED =
|
|
33
|
+
'Declare services.monitoring.backups in fleet.json (a schedule, how many to keep and S3-compatible storage off the manager) and the secrets monitoring-backup-access-key and monitoring-backup-secret-key; fleet-sync then sets them through the hub.';
|
|
34
|
+
const DECLARED =
|
|
35
|
+
"fleet-sync sets the backups declared under services.monitoring.backups. Check that the secrets monitoring-backup-access-key and monitoring-backup-secret-key are readable on the manager and that the manager's Vault session is logged in, then rerun fleet-sync.";
|
|
29
36
|
|
|
30
|
-
/** Reads
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* the
|
|
34
|
-
*
|
|
37
|
+
/** Reads the hub's own backup settings and its list of backups through the
|
|
38
|
+
* stock PocketBase API of the pinned release (superuser only: GET
|
|
39
|
+
* /api/settings and GET /api/backups): whether they are scheduled, kept off
|
|
40
|
+
* the manager and recent. fleet-sync sets them from the fleet configuration
|
|
41
|
+
* (`hub-darwin.yml`); with them declared, anything but that is a failure, a
|
|
42
|
+
* stale backup after a grace period. Storage names, keys and endpoints are
|
|
43
|
+
* never reported.
|
|
35
44
|
*/
|
|
36
45
|
export async function verifyHubBackups(
|
|
37
46
|
login: HubLogin | null,
|
|
38
47
|
fetcher: typeof fetch = fetch,
|
|
39
48
|
base: string = LOCAL_HUB,
|
|
40
|
-
|
|
49
|
+
options: { now?: number; declared?: HubBackups | undefined } = {}
|
|
41
50
|
): Promise<Check> {
|
|
51
|
+
const SETTINGS = options.declared ? DECLARED : UNDECLARED;
|
|
42
52
|
if (!login)
|
|
43
53
|
return backupCheck(
|
|
44
54
|
'skip',
|
|
@@ -69,31 +79,67 @@ export async function verifyHubBackups(
|
|
|
69
79
|
'BESZEL_BACKUP_UNVERIFIED',
|
|
70
80
|
`The hub answered in a form this release does not expect. ${SETTINGS}`
|
|
71
81
|
);
|
|
82
|
+
return judgeBackups(
|
|
83
|
+
policy,
|
|
84
|
+
newestBackup(backups),
|
|
85
|
+
options.now ?? Date.now(),
|
|
86
|
+
options.declared
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Undeclared backups only warn; declared ones fail when they are not what
|
|
91
|
+
* the fleet configuration says, or have not reached storage for longer than
|
|
92
|
+
* the grace period.
|
|
93
|
+
*/
|
|
94
|
+
function judgeBackups(
|
|
95
|
+
policy: { cron: string; keep: number; offHost: boolean },
|
|
96
|
+
newest: number | null,
|
|
97
|
+
now: number,
|
|
98
|
+
declared: HubBackups | undefined
|
|
99
|
+
): Check {
|
|
100
|
+
const SETTINGS = declared ? DECLARED : UNDECLARED;
|
|
101
|
+
const missing: Check['status'] = declared ? 'fail' : 'warn';
|
|
72
102
|
if (!policy.cron)
|
|
73
103
|
return backupCheck(
|
|
74
|
-
|
|
104
|
+
missing,
|
|
75
105
|
'BESZEL_BACKUP_UNSCHEDULED',
|
|
76
|
-
`The hub takes no scheduled backups, so its history, settings and machine identities are lost with the manager. ${SETTINGS}
|
|
106
|
+
`The hub takes no scheduled backups, so its history, settings and machine identities are lost with the manager. ${SETTINGS}`
|
|
77
107
|
);
|
|
78
108
|
if (!policy.offHost)
|
|
79
109
|
return backupCheck(
|
|
80
|
-
|
|
110
|
+
missing,
|
|
81
111
|
'BESZEL_BACKUP_OFFHOST_REQUIRED',
|
|
82
|
-
`Scheduled backups stay on the manager's disk, which is not disaster recovery. ${SETTINGS}
|
|
112
|
+
`Scheduled backups stay on the manager's disk, which is not disaster recovery. ${SETTINGS} Use encrypted S3-compatible storage you already approved; do not create new storage or reuse unrelated credentials.`
|
|
83
113
|
);
|
|
84
|
-
|
|
85
|
-
|
|
114
|
+
if (
|
|
115
|
+
declared &&
|
|
116
|
+
(policy.cron !== declared.schedule || policy.keep !== declared.keep)
|
|
117
|
+
)
|
|
86
118
|
return backupCheck(
|
|
87
|
-
'
|
|
119
|
+
'fail',
|
|
120
|
+
'BESZEL_BACKUP_NOT_CONVERGED',
|
|
121
|
+
`The hub's backup schedule is not the one the fleet configuration declares. ${SETTINGS}`
|
|
122
|
+
);
|
|
123
|
+
const age = newest === null ? null : now - newest;
|
|
124
|
+
if (age === null || age > BACKUP_MAX_AGE_MS)
|
|
125
|
+
return backupCheck(
|
|
126
|
+
staleStatus(age, declared !== undefined),
|
|
88
127
|
'BESZEL_BACKUP_STALE',
|
|
89
|
-
`No backup newer than ${BACKUP_MAX_AGE_MS / 3_600_000} hours is listed although a schedule is set.
|
|
128
|
+
`No backup newer than ${BACKUP_MAX_AGE_MS / 3_600_000} hours is listed although a schedule is set. Check the storage's credentials and that the hub can reach it, then create a backup by hand (the hub's /_/ page, Settings > Backups). ${SETTINGS}`
|
|
90
129
|
);
|
|
91
130
|
return backupCheck(
|
|
92
131
|
'pass',
|
|
93
|
-
`Scheduled backups go to off-host storage, keeping ${policy.keep}; the newest is from ${new Date(
|
|
132
|
+
`Scheduled backups go to off-host storage, keeping ${policy.keep}; the newest is from ${new Date(now - age).toISOString()}.`
|
|
94
133
|
);
|
|
95
134
|
}
|
|
96
135
|
|
|
136
|
+
/** A fresh hub has no backup yet; declared backups that stopped reaching
|
|
137
|
+
* storage fail once the grace period is over.
|
|
138
|
+
*/
|
|
139
|
+
function staleStatus(age: number | null, declared: boolean): Check['status'] {
|
|
140
|
+
return declared && age !== null && age > BACKUP_GRACE_MS ? 'fail' : 'warn';
|
|
141
|
+
}
|
|
142
|
+
|
|
97
143
|
function backupPolicy(
|
|
98
144
|
settings: unknown
|
|
99
145
|
): { cron: string; keep: number; offHost: boolean } | null {
|
|
@@ -8,6 +8,7 @@ import { publicViewerIdentity, fleetAccount } from './hub-access';
|
|
|
8
8
|
import { ensurePublicViewer, type HubLogin } from './hub-viewer';
|
|
9
9
|
import { verifyHubBackups } from './hub-backup';
|
|
10
10
|
import type { FleetDefinition } from '../definition';
|
|
11
|
+
import { secretVia } from '../fleet-config';
|
|
11
12
|
import { hostId, hubUrl } from '../topology';
|
|
12
13
|
import { publicHubUrl, privateHub } from './beszel';
|
|
13
14
|
import type { Check, Runner } from '../types';
|
|
@@ -56,6 +57,9 @@ export interface ManagerHubOptions {
|
|
|
56
57
|
receipts?: string;
|
|
57
58
|
/** Ensures the read-only AUTO_LOGIN user; defaults to the loopback hub API. */
|
|
58
59
|
viewer?: (login: HubLogin | null) => Promise<Check>;
|
|
60
|
+
/** The declared backups for the hub's playbook; defaults to the fleet
|
|
61
|
+
* configuration and its secrets. */
|
|
62
|
+
backupStorage?: () => Promise<Record<string, string | number | boolean>>;
|
|
59
63
|
/** Reads the hub's backup settings; defaults to the loopback hub API. */
|
|
60
64
|
backups?: (login: HubLogin | null) => Promise<Check>;
|
|
61
65
|
}
|
|
@@ -78,13 +82,19 @@ export async function convergeManagerHub(
|
|
|
78
82
|
const paths = managerHubPaths(options.home);
|
|
79
83
|
const tunnel = await tunnelToken(run);
|
|
80
84
|
const account = await (options.account ?? fleetAccount)();
|
|
85
|
+
const backups = await (
|
|
86
|
+
options.backupStorage ?? (() => backupVariables(definition, run))
|
|
87
|
+
)();
|
|
81
88
|
const monitoring = resolve(root, 'ansible/monitoring');
|
|
82
89
|
const result = await runAnsibleWithReceipt(
|
|
83
90
|
run,
|
|
84
91
|
'hub-darwin.yml',
|
|
85
92
|
{
|
|
86
93
|
cwd: monitoring,
|
|
87
|
-
input: JSON.stringify(
|
|
94
|
+
input: JSON.stringify({
|
|
95
|
+
...hubVariables(hub, paths, tunnel, account),
|
|
96
|
+
...backups,
|
|
97
|
+
}),
|
|
88
98
|
timeoutMs: 300_000,
|
|
89
99
|
},
|
|
90
100
|
{
|
|
@@ -127,7 +137,13 @@ export async function convergeManagerHub(
|
|
|
127
137
|
},
|
|
128
138
|
publicEndpointCheck(tunnel !== null),
|
|
129
139
|
await ensureViewer(options, account),
|
|
130
|
-
await (
|
|
140
|
+
await (
|
|
141
|
+
options.backups ??
|
|
142
|
+
((login) =>
|
|
143
|
+
verifyHubBackups(login, fetch, LOCAL_HUB, {
|
|
144
|
+
declared: definition.services.monitoring.backups,
|
|
145
|
+
}))
|
|
146
|
+
)(account),
|
|
131
147
|
];
|
|
132
148
|
}
|
|
133
149
|
|
|
@@ -153,6 +169,32 @@ function hubVariables(
|
|
|
153
169
|
};
|
|
154
170
|
}
|
|
155
171
|
|
|
172
|
+
/** The declared backup schedule and storage, with the storage's keys from
|
|
173
|
+
* the fleet's secrets; nothing when no backups are declared or a key is
|
|
174
|
+
* missing (beszel:backup then says so).
|
|
175
|
+
*/
|
|
176
|
+
export async function backupVariables(
|
|
177
|
+
definition: FleetDefinition,
|
|
178
|
+
run: Runner
|
|
179
|
+
): Promise<Record<string, string | number | boolean>> {
|
|
180
|
+
const declared = definition.services.monitoring.backups;
|
|
181
|
+
if (!declared) return {};
|
|
182
|
+
const secret = secretVia(run);
|
|
183
|
+
const accessKey = await secret('monitoring-backup-access-key');
|
|
184
|
+
const secretKey = await secret('monitoring-backup-secret-key');
|
|
185
|
+
if (!accessKey || !secretKey) return {};
|
|
186
|
+
return {
|
|
187
|
+
beszel_backup_schedule: declared.schedule,
|
|
188
|
+
beszel_backup_keep: declared.keep,
|
|
189
|
+
beszel_backup_bucket: declared.storage.bucket,
|
|
190
|
+
beszel_backup_endpoint: declared.storage.endpoint,
|
|
191
|
+
beszel_backup_region: declared.storage.region,
|
|
192
|
+
beszel_backup_force_path_style: declared.storage.forcePathStyle ?? false,
|
|
193
|
+
beszel_backup_access_key: accessKey,
|
|
194
|
+
beszel_backup_secret_key: secretKey,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
156
198
|
function ensureViewer(
|
|
157
199
|
options: ManagerHubOptions,
|
|
158
200
|
account: HubLogin | null
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
import { userInfo } from 'node:os';
|
|
23
23
|
import { resolve } from 'node:path';
|
|
24
24
|
import { auditUbuntu } from './platform/ubuntu-audit';
|
|
25
|
+
import { convergeGitIdentity } from './git-identity';
|
|
26
|
+
import { workerSelection } from '../bootstrap/worker-tools';
|
|
25
27
|
import {
|
|
26
28
|
applyAgentProfiles,
|
|
27
29
|
attempt,
|
|
@@ -59,7 +61,7 @@ export async function convergeLinux(
|
|
|
59
61
|
await applyTailscale(root, run),
|
|
60
62
|
]))
|
|
61
63
|
);
|
|
62
|
-
const { baseline, verified } = await baselineStep(root, run);
|
|
64
|
+
const { baseline, verified } = await baselineStep(root, run, node);
|
|
63
65
|
checks.push(...baseline, ...verified);
|
|
64
66
|
checks.push(...(await t3AfterBaseline(root, { node, baseline, run })));
|
|
65
67
|
checks.push(
|
|
@@ -67,6 +69,11 @@ export async function convergeLinux(
|
|
|
67
69
|
configureWorkstation(root, run)
|
|
68
70
|
))
|
|
69
71
|
);
|
|
72
|
+
checks.push(
|
|
73
|
+
...(await attempt('git:configuration', () =>
|
|
74
|
+
convergeGitIdentity(root, fleet.git, run)
|
|
75
|
+
))
|
|
76
|
+
);
|
|
70
77
|
checks.push(
|
|
71
78
|
...(await attempt('configuration:apply', () =>
|
|
72
79
|
applyAgentProfiles(root, node, revision, run)
|
|
@@ -110,7 +117,8 @@ export async function convergeLinux(
|
|
|
110
117
|
*/
|
|
111
118
|
async function baselineStep(
|
|
112
119
|
root: string,
|
|
113
|
-
run: Runner
|
|
120
|
+
run: Runner,
|
|
121
|
+
node: string
|
|
114
122
|
): Promise<{ baseline: Check[]; verified: Check[] }> {
|
|
115
123
|
const measure = async () => [
|
|
116
124
|
...(await attempt('ubuntu:boot-policy', () =>
|
|
@@ -122,14 +130,14 @@ async function baselineStep(
|
|
|
122
130
|
];
|
|
123
131
|
let cached = false;
|
|
124
132
|
let baseline = await attempt('ubuntu:baseline', async () => {
|
|
125
|
-
const applied = await applyBaseline(root, run, false);
|
|
133
|
+
const applied = await applyBaseline(root, run, false, node);
|
|
126
134
|
cached = applied.cached;
|
|
127
135
|
return applied.checks;
|
|
128
136
|
});
|
|
129
137
|
let verified = await measure();
|
|
130
138
|
if (cached && verified.some((check) => check.status === 'fail')) {
|
|
131
139
|
baseline = await attempt('ubuntu:baseline', async () => {
|
|
132
|
-
return (await applyBaseline(root, run, true)).checks;
|
|
140
|
+
return (await applyBaseline(root, run, true, node)).checks;
|
|
133
141
|
});
|
|
134
142
|
verified = await measure();
|
|
135
143
|
}
|
|
@@ -139,7 +147,8 @@ async function baselineStep(
|
|
|
139
147
|
async function applyBaseline(
|
|
140
148
|
root: string,
|
|
141
149
|
run: Runner,
|
|
142
|
-
force: boolean
|
|
150
|
+
force: boolean,
|
|
151
|
+
node: string
|
|
143
152
|
): Promise<{ cached: boolean; checks: Check[] }> {
|
|
144
153
|
const user = userInfo();
|
|
145
154
|
const ubuntu = resolve(root, 'ansible/ubuntu');
|
|
@@ -154,6 +163,8 @@ async function applyBaseline(
|
|
|
154
163
|
development_user: user.username,
|
|
155
164
|
development_uid: user.uid,
|
|
156
165
|
development_home: user.homedir,
|
|
166
|
+
// The web profile brings headless Chromium (tasks/browser.yml).
|
|
167
|
+
worker_tool_profiles: workerSelection(fleet, node).profiles,
|
|
157
168
|
}),
|
|
158
169
|
},
|
|
159
170
|
{
|
package/src/node-converge.ts
CHANGED
|
@@ -23,7 +23,7 @@ async function main(): Promise<Check[]> {
|
|
|
23
23
|
summary: 'PLATFORM_UNSUPPORTED',
|
|
24
24
|
remedy: {
|
|
25
25
|
action:
|
|
26
|
-
'Workers
|
|
26
|
+
'Workers run an Ubuntu release docs/support-matrix.md lists. The macOS manager converges itself locally in fleet-sync.',
|
|
27
27
|
},
|
|
28
28
|
},
|
|
29
29
|
];
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { record } from './config';
|
|
2
|
+
import type { SecretName, SecretsProvider } from './secrets';
|
|
3
|
+
import type { Check, NodeReport } from './types';
|
|
4
|
+
|
|
5
|
+
/** Where fleet-watch sends a notification. The address of each target is a
|
|
6
|
+
* secret, read by purpose: a webhook or ntfy URL often carries its own token.
|
|
7
|
+
*/
|
|
8
|
+
export const NOTIFY_TARGETS = {
|
|
9
|
+
webhook: 'notify-webhook-url',
|
|
10
|
+
ntfy: 'notify-ntfy-url',
|
|
11
|
+
} as const satisfies Record<string, SecretName>;
|
|
12
|
+
export type NotifyTarget = keyof typeof NOTIFY_TARGETS;
|
|
13
|
+
|
|
14
|
+
export interface NotifyConfig {
|
|
15
|
+
targets: NotifyTarget[];
|
|
16
|
+
/** Notify again about a machine that stays unhealthy this often; 0 never. */
|
|
17
|
+
repeatHours: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_REPEAT_HOURS = 24;
|
|
21
|
+
|
|
22
|
+
/** The `notify` block of a fleet configuration, or null when it is invalid. */
|
|
23
|
+
export function parseNotify(value: unknown): NotifyConfig | null {
|
|
24
|
+
if (!record(value)) return null;
|
|
25
|
+
if (
|
|
26
|
+
Object.keys(value).some((key) => !['targets', 'repeatHours'].includes(key))
|
|
27
|
+
)
|
|
28
|
+
return null;
|
|
29
|
+
const { targets, repeatHours = DEFAULT_REPEAT_HOURS } = value;
|
|
30
|
+
if (
|
|
31
|
+
!Array.isArray(targets) ||
|
|
32
|
+
targets.length === 0 ||
|
|
33
|
+
new Set(targets).size !== targets.length ||
|
|
34
|
+
!targets.every(
|
|
35
|
+
(target) =>
|
|
36
|
+
typeof target === 'string' && Object.hasOwn(NOTIFY_TARGETS, target)
|
|
37
|
+
) ||
|
|
38
|
+
typeof repeatHours !== 'number' ||
|
|
39
|
+
!Number.isInteger(repeatHours) ||
|
|
40
|
+
repeatHours < 0 ||
|
|
41
|
+
repeatHours > 720
|
|
42
|
+
)
|
|
43
|
+
return null;
|
|
44
|
+
return { targets: targets as NotifyTarget[], repeatHours };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the watcher remembers of each machine between two runs. */
|
|
48
|
+
export interface WatchMemory {
|
|
49
|
+
version: 1;
|
|
50
|
+
/** When the watcher last ran: the heartbeat an off-machine check reads. */
|
|
51
|
+
heartbeatAt: string;
|
|
52
|
+
nodes: Record<
|
|
53
|
+
string,
|
|
54
|
+
{ state: 'ready' | 'degraded'; since: string; notifiedAt: string | null }
|
|
55
|
+
>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One machine as fleet-status saw it: the IDs of its failing checks. */
|
|
59
|
+
export interface Observation {
|
|
60
|
+
node: string;
|
|
61
|
+
failing: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface Notification {
|
|
65
|
+
event: 'degraded' | 'recovered' | 'still-degraded' | 'test';
|
|
66
|
+
node: string;
|
|
67
|
+
checks: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Only what a report concluded: the required checks that failed. */
|
|
71
|
+
export function observe(nodes: NodeReport[]): Observation[] {
|
|
72
|
+
return nodes.map((node) => ({
|
|
73
|
+
node: node.node,
|
|
74
|
+
failing: node.checks
|
|
75
|
+
.filter((check) => check.status === 'fail' && check.required !== false)
|
|
76
|
+
.map((check) => check.id),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Notifications on transitions only (ready to degraded and back), and a
|
|
81
|
+
* reminder for a machine that stays degraded longer than the repeat interval.
|
|
82
|
+
* A machine never seen before counts as ready.
|
|
83
|
+
*/
|
|
84
|
+
export function transitions(
|
|
85
|
+
previous: WatchMemory | null,
|
|
86
|
+
observed: Observation[],
|
|
87
|
+
now: Date,
|
|
88
|
+
repeatHours: number
|
|
89
|
+
): { notifications: Notification[]; memory: WatchMemory } {
|
|
90
|
+
const at = now.toISOString();
|
|
91
|
+
const notifications: Notification[] = [];
|
|
92
|
+
const nodes: WatchMemory['nodes'] = {};
|
|
93
|
+
for (const { node, failing } of observed) {
|
|
94
|
+
const before = previous?.nodes[node];
|
|
95
|
+
const state = failing.length ? 'degraded' : 'ready';
|
|
96
|
+
const checks = failing.filter(safe);
|
|
97
|
+
if (state === (before?.state ?? 'ready')) {
|
|
98
|
+
const due = state === 'degraded' && repeatDue(before, now, repeatHours);
|
|
99
|
+
if (due) notifications.push({ event: 'still-degraded', node, checks });
|
|
100
|
+
nodes[node] = before
|
|
101
|
+
? { ...before, ...(due ? { notifiedAt: at } : {}) }
|
|
102
|
+
: { state, since: at, notifiedAt: null };
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
notifications.push({
|
|
106
|
+
event: state === 'degraded' ? 'degraded' : 'recovered',
|
|
107
|
+
node,
|
|
108
|
+
checks,
|
|
109
|
+
});
|
|
110
|
+
nodes[node] = { state, since: at, notifiedAt: at };
|
|
111
|
+
}
|
|
112
|
+
return { notifications, memory: { version: 1, heartbeatAt: at, nodes } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function repeatDue(
|
|
116
|
+
before: WatchMemory['nodes'][string] | undefined,
|
|
117
|
+
now: Date,
|
|
118
|
+
repeatHours: number
|
|
119
|
+
): boolean {
|
|
120
|
+
if (repeatHours <= 0 || !before?.notifiedAt) return false;
|
|
121
|
+
return (
|
|
122
|
+
now.getTime() - Date.parse(before.notifiedAt) >= repeatHours * 3_600_000
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The memory stored last time, or null when there is none to trust. */
|
|
127
|
+
export function parseMemory(text: string | null): WatchMemory | null {
|
|
128
|
+
if (!text) return null;
|
|
129
|
+
try {
|
|
130
|
+
const value: unknown = JSON.parse(text);
|
|
131
|
+
if (
|
|
132
|
+
!record(value) ||
|
|
133
|
+
value['version'] !== 1 ||
|
|
134
|
+
typeof value['heartbeatAt'] !== 'string' ||
|
|
135
|
+
!record(value['nodes'])
|
|
136
|
+
)
|
|
137
|
+
return null;
|
|
138
|
+
return value as unknown as WatchMemory;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Node and check IDs are the only words a notification carries. */
|
|
145
|
+
const safe = (id: string): boolean => /^[A-Za-z0-9:._@-]{1,128}$/.test(id);
|
|
146
|
+
|
|
147
|
+
const WORDS: Record<Notification['event'], string> = {
|
|
148
|
+
degraded: 'is degraded',
|
|
149
|
+
recovered: 'is ready again',
|
|
150
|
+
'still-degraded': 'is still degraded',
|
|
151
|
+
test: 'sent a test notification',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export function notificationText(notification: Notification): string {
|
|
155
|
+
const node = safe(notification.node) ? notification.node : 'a machine';
|
|
156
|
+
const checks = notification.checks.filter(safe);
|
|
157
|
+
return `dev-fleet: ${node} ${WORDS[notification.event]}${
|
|
158
|
+
checks.length ? `. Failing checks: ${checks.join(', ')}` : ''
|
|
159
|
+
}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Sends one notification to every target. The result names the target and
|
|
163
|
+
* whether it took the notification: never its address or its answer.
|
|
164
|
+
*/
|
|
165
|
+
export async function deliver(
|
|
166
|
+
config: NotifyConfig,
|
|
167
|
+
notification: Notification,
|
|
168
|
+
secrets: SecretsProvider,
|
|
169
|
+
send: typeof fetch = fetch
|
|
170
|
+
): Promise<Check[]> {
|
|
171
|
+
const text = notificationText(notification);
|
|
172
|
+
const checks: Check[] = [];
|
|
173
|
+
for (const target of config.targets) {
|
|
174
|
+
const id = `notify:${target}`;
|
|
175
|
+
const url = await secrets(NOTIFY_TARGETS[target]);
|
|
176
|
+
if (!url || !/^https?:\/\//.test(url)) {
|
|
177
|
+
checks.push({
|
|
178
|
+
id,
|
|
179
|
+
status: 'fail',
|
|
180
|
+
summary: 'NOTIFY_TARGET_UNAVAILABLE',
|
|
181
|
+
remedy: {
|
|
182
|
+
action: `Store the address of the ${target} target as the secret "${NOTIFY_TARGETS[target]}" with the secret provider fleet.json names.`,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const ok = await send(url, request(target, notification, text))
|
|
188
|
+
.then((response) => response.ok)
|
|
189
|
+
.catch(() => false);
|
|
190
|
+
checks.push(
|
|
191
|
+
ok
|
|
192
|
+
? {
|
|
193
|
+
id,
|
|
194
|
+
status: 'pass',
|
|
195
|
+
summary: `The ${target} target took the notification.`,
|
|
196
|
+
}
|
|
197
|
+
: {
|
|
198
|
+
id,
|
|
199
|
+
status: 'fail',
|
|
200
|
+
summary: 'NOTIFY_DELIVERY_FAILED',
|
|
201
|
+
remedy: {
|
|
202
|
+
action: `Check that the ${target} target answers, then run devfleet fleet-watch --test.`,
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return checks;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function request(
|
|
211
|
+
target: NotifyTarget,
|
|
212
|
+
notification: Notification,
|
|
213
|
+
text: string
|
|
214
|
+
): RequestInit {
|
|
215
|
+
const signal = AbortSignal.timeout(15_000);
|
|
216
|
+
if (target === 'ntfy')
|
|
217
|
+
return {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
body: text,
|
|
220
|
+
headers: {
|
|
221
|
+
Title: `dev-fleet: ${notification.event}`,
|
|
222
|
+
Tags:
|
|
223
|
+
notification.event === 'recovered' ? 'white_check_mark' : 'warning',
|
|
224
|
+
},
|
|
225
|
+
signal,
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'Content-Type': 'application/json' },
|
|
230
|
+
body: JSON.stringify({
|
|
231
|
+
source: 'dev-fleet',
|
|
232
|
+
event: notification.event,
|
|
233
|
+
node: safe(notification.node) ? notification.node : null,
|
|
234
|
+
checks: notification.checks.filter(safe),
|
|
235
|
+
text,
|
|
236
|
+
}),
|
|
237
|
+
signal,
|
|
238
|
+
};
|
|
239
|
+
}
|