@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.
Files changed (97) hide show
  1. package/README.md +25 -17
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/policy.json +12 -1
  4. package/ansible/monitoring/compose.yml +10 -0
  5. package/ansible/monitoring/container-watchdog.py +118 -0
  6. package/ansible/monitoring/hub-darwin.yml +146 -0
  7. package/ansible/net/remote-probe.sh +5 -0
  8. package/ansible/platforms.json +111 -0
  9. package/ansible/ubuntu/authorize-sudo.sh +7 -16
  10. package/ansible/ubuntu/baseline.yml +6 -28
  11. package/ansible/ubuntu/disk-gc.py +211 -7
  12. package/ansible/ubuntu/disk-guard.py +24 -5
  13. package/ansible/ubuntu/policy.json +68 -4
  14. package/ansible/ubuntu/t3-desktop.yml +2 -2
  15. package/ansible/ubuntu/tailscale.yml +20 -9
  16. package/ansible/ubuntu/tasks/browser.yml +56 -0
  17. package/ansible/ubuntu/tasks/docker.yml +52 -0
  18. package/ansible/ubuntu/tasks/dpkg.yml +46 -0
  19. package/ansible/ubuntu/tasks/install.yml +38 -0
  20. package/ansible/ubuntu/tasks/packages.yml +4 -55
  21. package/ansible/ubuntu/tasks/release.yml +25 -0
  22. package/ansible/ubuntu/tasks/resources.yml +7 -6
  23. package/ansible/ubuntu/tasks/services.yml +13 -0
  24. package/ansible/ubuntu/tasks/snapshot.yml +50 -0
  25. package/ansible/ubuntu/tasks/unattended-upgrades.yml +24 -0
  26. package/bootstrap/doctor.ts +32 -6
  27. package/bootstrap/fleet-remote-bootstrap.sh +49 -45
  28. package/bootstrap/setup.ts +23 -8
  29. package/bootstrap/worker-tools.ts +118 -0
  30. package/cliproxy/README.md +1 -1
  31. package/cliproxy/compose.yaml +4 -0
  32. package/cliproxy/scripts/checkpoint.py +1 -1
  33. package/docs/authentication.md +90 -0
  34. package/docs/consumer-setup.md +32 -30
  35. package/docs/declared.md +6 -5
  36. package/docs/machine-setup.md +22 -12
  37. package/docs/operations.md +87 -31
  38. package/docs/runbooks.md +377 -0
  39. package/docs/state.md +13 -7
  40. package/docs/support-matrix.md +36 -0
  41. package/docs/ubuntu-sync.md +16 -13
  42. package/docs/worker-contract.md +28 -1
  43. package/examples/fleet.json +19 -1
  44. package/llms.txt +2 -0
  45. package/mise.toml +7 -0
  46. package/package.json +2 -2
  47. package/schema/check-ids.json +526 -0
  48. package/schema/fleet.schema.json +185 -4
  49. package/schema/output/check.schema.json +23 -0
  50. package/schema/output/error.schema.json +14 -0
  51. package/schema/output/fleet-report.schema.json +79 -0
  52. package/schema/output/fleet-sync.schema.json +112 -0
  53. package/schema/output/report.schema.json +38 -0
  54. package/src/auth-checks.ts +3 -0
  55. package/src/cli/agent-guide.ts +5 -3
  56. package/src/cli/fleet-report.ts +126 -0
  57. package/src/cli/fleet-sync-manager.ts +11 -0
  58. package/src/cli/fleet-sync-plan.ts +200 -0
  59. package/src/cli/fleet-sync-remote.ts +4 -2
  60. package/src/cli/fleet-sync-schedule.ts +142 -35
  61. package/src/cli/fleet-sync.ts +90 -8
  62. package/src/cli/fleet-watch.ts +198 -0
  63. package/src/cli/fleet.ts +4 -0
  64. package/src/cli/register-fleet.ts +51 -0
  65. package/src/cli/registry.ts +13 -0
  66. package/src/cli/schedule-logs.ts +36 -0
  67. package/src/clock.ts +63 -0
  68. package/src/definition.ts +160 -12
  69. package/src/git-identity.ts +160 -0
  70. package/src/machine/platform.ts +48 -28
  71. package/src/machine/support-matrix.ts +76 -0
  72. package/src/machine-health.ts +51 -4
  73. package/src/manager-health.ts +205 -0
  74. package/src/manager-sync.ts +27 -9
  75. package/src/monitoring/hub-backup.ts +63 -17
  76. package/src/monitoring/manager-hub.ts +44 -2
  77. package/src/network-definition.ts +1 -1
  78. package/src/node-converge-linux.ts +16 -5
  79. package/src/node-converge.ts +1 -1
  80. package/src/notify.ts +239 -0
  81. package/src/platform/debian-version.ts +62 -0
  82. package/src/platform/ubuntu-audit.ts +11 -4
  83. package/src/platform/ubuntu-release.ts +41 -0
  84. package/src/remote-bootstrap.ts +89 -25
  85. package/src/remote-operation.ts +213 -0
  86. package/src/schedule-check.ts +40 -0
  87. package/src/secrets.ts +26 -8
  88. package/src/ssh.ts +10 -2
  89. package/src/state/history.ts +172 -0
  90. package/src/state/store.ts +11 -4
  91. package/src/tailscale.ts +51 -0
  92. package/src/toolchain-checks.ts +8 -3
  93. package/src/worker-capability-checks.ts +167 -0
  94. package/src/worker-capacity.ts +19 -7
  95. package/src/worker-host-checks.ts +68 -10
  96. package/src/worker-policy.ts +33 -0
  97. package/src/worker-readiness.ts +10 -1
@@ -6,16 +6,19 @@ import {
6
6
  type NodeReport,
7
7
  type Runner,
8
8
  } from './types';
9
- import { discoverTailnet, type TailnetComputer } from './tailscale';
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
- convergeable,
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(os, result.stdout);
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: tailnet.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.filter(convergeable).map((machine) => {
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, machine.os)));
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 SETTINGS =
28
- 'Open the hub as its superuser (/_/ on the manager), Settings > Backups.';
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, never changes, the hub's own backup settings and its list of
31
- * backups through the stock PocketBase API of the pinned release (superuser
32
- * only: GET /api/settings and GET /api/backups). Backups are configured in
33
- * the hub's UI; this says whether they are scheduled, kept off the manager
34
- * and recent. Storage names, keys and endpoints are never reported.
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
- now: number = Date.now()
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
- 'warn',
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} Set a schedule and how many to keep.`
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
- 'warn',
110
+ missing,
81
111
  'BESZEL_BACKUP_OFFHOST_REQUIRED',
82
- `Scheduled backups stay on the manager's disk, which is not disaster recovery. ${SETTINGS} Store them in existing encrypted S3-compatible storage you already approved; do not create new storage or reuse unrelated credentials.`
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
- const newest = newestBackup(backups);
85
- if (newest === null || now - newest > BACKUP_MAX_AGE_MS)
114
+ if (
115
+ declared &&
116
+ (policy.cron !== declared.schedule || policy.keep !== declared.keep)
117
+ )
86
118
  return backupCheck(
87
- 'warn',
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. ${SETTINGS} Check the schedule and the storage's credentials, then create a backup by hand.`
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(newest).toISOString()}.`
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(hubVariables(hub, paths, tunnel, account)),
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 (options.backups ?? verifyHubBackups)(account),
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
@@ -8,7 +8,7 @@ export interface NetworkHost {
8
8
  sshPort: number;
9
9
  user: string;
10
10
  identityFile: string;
11
- os: 'linux' | 'macOS' | 'windows';
11
+ os: 'linux';
12
12
  t3Port: number;
13
13
  }
14
14
  export function networkProjection(
@@ -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
  {
@@ -23,7 +23,7 @@ async function main(): Promise<Check[]> {
23
23
  summary: 'PLATFORM_UNSUPPORTED',
24
24
  remedy: {
25
25
  action:
26
- 'Workers are Ubuntu 26.04. The macOS manager converges itself locally in fleet-sync.',
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
+ }