@crvouga/dev-fleet 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +22 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/policy.json +11 -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/ubuntu/baseline.yml +2 -0
  9. package/ansible/ubuntu/disk-gc.py +25 -3
  10. package/ansible/ubuntu/disk-guard.py +24 -5
  11. package/ansible/ubuntu/policy.json +50 -1
  12. package/ansible/ubuntu/tasks/browser.yml +56 -0
  13. package/ansible/ubuntu/tasks/docker.yml +52 -0
  14. package/ansible/ubuntu/tasks/resources.yml +2 -2
  15. package/ansible/ubuntu/tasks/services.yml +13 -0
  16. package/bootstrap/doctor.ts +32 -6
  17. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  18. package/bootstrap/setup.ts +23 -8
  19. package/bootstrap/worker-tools.ts +118 -0
  20. package/cliproxy/README.md +1 -1
  21. package/cliproxy/compose.yaml +4 -0
  22. package/cliproxy/scripts/checkpoint.py +1 -1
  23. package/docs/authentication.md +90 -0
  24. package/docs/declared.md +2 -0
  25. package/docs/machine-setup.md +22 -12
  26. package/docs/operations.md +87 -31
  27. package/docs/runbooks.md +380 -0
  28. package/docs/state.md +13 -7
  29. package/docs/ubuntu-sync.md +7 -6
  30. package/examples/fleet.json +19 -1
  31. package/llms.txt +2 -0
  32. package/mise.toml +7 -0
  33. package/package.json +2 -2
  34. package/schema/check-ids.json +522 -0
  35. package/schema/fleet.schema.json +178 -1
  36. package/schema/output/check.schema.json +23 -0
  37. package/schema/output/error.schema.json +14 -0
  38. package/schema/output/fleet-report.schema.json +79 -0
  39. package/schema/output/fleet-sync.schema.json +112 -0
  40. package/schema/output/report.schema.json +38 -0
  41. package/src/auth-checks.ts +3 -0
  42. package/src/cli/agent-guide.ts +5 -3
  43. package/src/cli/fleet-report.ts +126 -0
  44. package/src/cli/fleet-sync-manager.ts +11 -0
  45. package/src/cli/fleet-sync-plan.ts +200 -0
  46. package/src/cli/fleet-sync-remote.ts +3 -1
  47. package/src/cli/fleet-sync-schedule.ts +142 -35
  48. package/src/cli/fleet-sync.ts +90 -8
  49. package/src/cli/fleet-watch.ts +198 -0
  50. package/src/cli/fleet.ts +4 -0
  51. package/src/cli/register-fleet.ts +51 -0
  52. package/src/cli/registry.ts +13 -0
  53. package/src/cli/schedule-logs.ts +36 -0
  54. package/src/clock.ts +63 -0
  55. package/src/definition.ts +142 -5
  56. package/src/git-identity.ts +160 -0
  57. package/src/machine-health.ts +51 -4
  58. package/src/manager-health.ts +205 -0
  59. package/src/manager-sync.ts +12 -2
  60. package/src/monitoring/hub-backup.ts +63 -17
  61. package/src/monitoring/manager-hub.ts +44 -2
  62. package/src/node-converge-linux.ts +16 -5
  63. package/src/notify.ts +239 -0
  64. package/src/remote-bootstrap.ts +87 -25
  65. package/src/remote-operation.ts +213 -0
  66. package/src/schedule-check.ts +40 -0
  67. package/src/secrets.ts +26 -8
  68. package/src/ssh.ts +10 -2
  69. package/src/state/history.ts +172 -0
  70. package/src/state/store.ts +11 -4
  71. package/src/tailscale.ts +51 -0
  72. package/src/toolchain-checks.ts +8 -3
  73. package/src/worker-capability-checks.ts +167 -0
  74. package/src/worker-capacity.ts +19 -7
  75. package/src/worker-host-checks.ts +32 -0
  76. package/src/worker-policy.ts +33 -0
  77. package/src/worker-readiness.ts +10 -1
@@ -0,0 +1,160 @@
1
+ import { homedir } from 'node:os';
2
+ import { resolve } from 'node:path';
3
+ import { runAnsible } from './platform/ansible';
4
+ import type { GitIdentity } from './definition';
5
+ import type { Check, Runner } from './types';
6
+
7
+ /** `ansible/github/git.yml` writes the declared identity and the GitHub
8
+ * credential helper in the node's own Git configuration; the two checks
9
+ * below measure them afterwards. The same on the manager and every worker.
10
+ */
11
+ export async function convergeGitIdentity(
12
+ root: string,
13
+ git: GitIdentity | undefined,
14
+ run: Runner,
15
+ home: string = homedir()
16
+ ): Promise<Check[]> {
17
+ if (!git) return [];
18
+ const result = await runAnsible(
19
+ run,
20
+ resolve(root, 'ansible/github/git.yml'),
21
+ {
22
+ cwd: resolve(root, 'ansible/github'),
23
+ timeoutMs: 120_000,
24
+ input: JSON.stringify({
25
+ git_name: git.name,
26
+ git_email: git.email,
27
+ git_home: home,
28
+ }),
29
+ }
30
+ );
31
+ return [
32
+ {
33
+ id: 'git:configuration',
34
+ status: result.ok ? 'pass' : 'fail',
35
+ summary: result.ok
36
+ ? 'The declared Git identity and the GitHub credential helper are written in ~/.config/git/config.'
37
+ : 'GIT_CONFIGURATION_INCOMPLETE',
38
+ ...(result.ok
39
+ ? {}
40
+ : {
41
+ remedy: {
42
+ action:
43
+ 'Inspect ~/.config/git/config and ~/.gitconfig on this node: a ~/.gitconfig that sets user.name, user.email or a GitHub credential helper wins over the fleet. Remove those lines and rerun fleet-sync.',
44
+ },
45
+ }),
46
+ },
47
+ ];
48
+ }
49
+
50
+ /** Git with no prompt: a missing credential fails instead of waiting. */
51
+ const QUIET = {
52
+ GIT_TERMINAL_PROMPT: '0',
53
+ GCM_INTERACTIVE: 'never',
54
+ GIT_ASKPASS: '',
55
+ SSH_ASKPASS: '',
56
+ };
57
+
58
+ async function effective(run: Runner, key: string): Promise<string> {
59
+ const result = await run(['git', 'config', '--global', '--get-all', key], {
60
+ timeoutMs: 10_000,
61
+ });
62
+ return result.ok ? (result.stdout.trim().split('\n').pop() ?? '') : '';
63
+ }
64
+
65
+ /** Commits carry the declared author: the effective `user.name` and
66
+ * `user.email` outside any repository are the fleet's.
67
+ */
68
+ export async function gitIdentityCheck(
69
+ git: GitIdentity | undefined,
70
+ run: Runner
71
+ ): Promise<Check> {
72
+ const id = 'worker:git-identity';
73
+ if (!git)
74
+ return {
75
+ id,
76
+ status: 'warn',
77
+ summary: 'GIT_IDENTITY_UNDECLARED',
78
+ remedy: {
79
+ action:
80
+ 'Declare git.name and git.email in fleet.json and rerun fleet-sync; until then commits on this node carry whatever author it happens to have.',
81
+ },
82
+ };
83
+ const name = await effective(run, 'user.name');
84
+ const email = await effective(run, 'user.email');
85
+ if (name === git.name && email === git.email)
86
+ return {
87
+ id,
88
+ status: 'pass',
89
+ summary: `Commits on this node are authored as ${git.name} <${git.email}>.`,
90
+ };
91
+ return {
92
+ id,
93
+ status: 'fail',
94
+ summary: !name || !email ? 'GIT_IDENTITY_MISSING' : 'GIT_IDENTITY_MISMATCH',
95
+ remedy: {
96
+ action:
97
+ 'Rerun fleet-sync: it writes the identity in ~/.config/git/config. A ~/.gitconfig that sets another user.name or user.email wins over it; remove those lines.',
98
+ },
99
+ };
100
+ }
101
+
102
+ /** A push is authorized through git itself, never through gh: Git's
103
+ * credential helper must answer for github.com, and the declared repository
104
+ * must answer an authenticated listing. The credential is read and dropped;
105
+ * it is never kept, printed or reported.
106
+ */
107
+ export async function gitPushAuthCheck(
108
+ git: GitIdentity | undefined,
109
+ run: Runner
110
+ ): Promise<Check> {
111
+ const id = 'worker:git-push-auth';
112
+ const fill = await run(['git', 'credential', 'fill'], {
113
+ input: 'protocol=https\nhost=github.com\n\n',
114
+ env: QUIET,
115
+ timeoutMs: 20_000,
116
+ });
117
+ if (!fill.ok || !/^password=./m.test(fill.stdout))
118
+ return {
119
+ id,
120
+ status: 'fail',
121
+ summary: 'GIT_CREDENTIAL_HELPER_MISSING',
122
+ remedy: {
123
+ action:
124
+ 'Git has no credential for github.com, so an agent cannot push over HTTPS. Rerun fleet-sync, which declares gh as the credential helper; if gh is signed out, the worker signs in again from its own Vault session (auth:github).',
125
+ },
126
+ };
127
+ if (git?.verifyRepository) {
128
+ const listed = await run(
129
+ ['git', 'ls-remote', '--heads', git.verifyRepository],
130
+ { env: QUIET, timeoutMs: 30_000 }
131
+ );
132
+ if (!listed.ok)
133
+ return {
134
+ id,
135
+ status: 'fail',
136
+ summary:
137
+ listed.reason === 'timeout'
138
+ ? 'GIT_REMOTE_UNREACHABLE'
139
+ : 'GIT_REMOTE_REFUSED',
140
+ remedy: {
141
+ action:
142
+ "The declared repository (git.verifyRepository) did not answer git with this node's credentials. Check that the fleet's GitHub account can push to it and that github.com is reachable.",
143
+ },
144
+ };
145
+ }
146
+ return {
147
+ id,
148
+ status: 'pass',
149
+ summary: git?.verifyRepository
150
+ ? "Git's credential helper answers for github.com and the declared repository answers through git."
151
+ : "Git's credential helper answers for github.com; declare git.verifyRepository to prove a repository too.",
152
+ };
153
+ }
154
+
155
+ export async function gitChecks(
156
+ git: GitIdentity | undefined,
157
+ run: Runner
158
+ ): Promise<Check[]> {
159
+ return [await gitIdentityCheck(git, run), await gitPushAuthCheck(git, run)];
160
+ }
@@ -1,3 +1,4 @@
1
+ import policy from '../ansible/ubuntu/policy.json';
1
2
  import type { DoctorCheck } from './doctor-check';
2
3
 
3
4
  /** Machine health sample parsed from `net/remote-probe.sh` output. */
@@ -7,6 +8,14 @@ export type HealthSample = {
7
8
  readonly memFreePct?: number;
8
9
  readonly swapPct?: number;
9
10
  readonly diskMb?: number;
11
+ /** Free inodes on the home filesystem, as a percentage of all of them. */
12
+ readonly inodesFreePct?: number;
13
+ /** 1 when the clock is synchronised to a time source, 0 when not. */
14
+ readonly clockSynced?: number;
15
+ /** 1 when a time service is enabled, 0 when not. */
16
+ readonly timeService?: number;
17
+ /** The machine's clock, in seconds since the epoch. */
18
+ readonly epoch?: number;
10
19
  };
11
20
 
12
21
  const PROBE_KEYS = {
@@ -15,6 +24,10 @@ const PROBE_KEYS = {
15
24
  memfree: 'memFreePct',
16
25
  swap: 'swapPct',
17
26
  diskmb: 'diskMb',
27
+ inodefree: 'inodesFreePct',
28
+ ntpsync: 'clockSynced',
29
+ ntp: 'timeService',
30
+ epoch: 'epoch',
18
31
  } as const;
19
32
 
20
33
  function isProbeKey(key: string): key is keyof typeof PROBE_KEYS {
@@ -33,7 +46,12 @@ export function parseProbe(output: string): HealthSample {
33
46
  return sample;
34
47
  }
35
48
 
36
- const LABELS = { load: 'CPU load', memory: 'Memory', disk: 'Disk space' };
49
+ const LABELS = {
50
+ load: 'CPU load',
51
+ memory: 'Memory',
52
+ disk: 'Disk space',
53
+ inodes: 'Free inodes',
54
+ };
37
55
 
38
56
  function check(
39
57
  id: keyof typeof LABELS,
@@ -88,8 +106,37 @@ export function diskCheck(diskMb: number | undefined): DoctorCheck | null {
88
106
  return check('disk', 'pass', `${Math.floor(diskMb / 1024)} GB free`);
89
107
  }
90
108
 
109
+ /** Free inodes, with the floors `policy.json` gives the disk guard: a
110
+ * filesystem with free bytes and no free inodes fails every write.
111
+ */
112
+ export function inodesCheck(
113
+ inodesFreePct: number | undefined,
114
+ floors = INODE_FLOORS
115
+ ): DoctorCheck | null {
116
+ if (inodesFreePct === undefined) return null;
117
+ const what = `${inodesFreePct}% of inodes free`;
118
+ if (inodesFreePct < floors.failPercent)
119
+ return check(
120
+ 'inodes',
121
+ 'fail',
122
+ `INODES_EXHAUSTED, ${what}. Every new file fails although bytes are free.`,
123
+ 'Remove idle dependency trees and worktrees: the hourly disk collector does so under pressure, or delete them by hand.'
124
+ );
125
+ if (inodesFreePct < floors.warnPercent)
126
+ return check('inodes', 'warn', `low, ${what}`);
127
+ return check('inodes', 'pass', what);
128
+ }
129
+
130
+ const INODE_FLOORS = {
131
+ warnPercent: policy.resourceSafety.inodeWarnFreePercent,
132
+ failPercent: policy.resourceSafety.inodeFailFreePercent,
133
+ };
134
+
91
135
  export function healthChecks(s: HealthSample, alias: string): DoctorCheck[] {
92
- return [loadCheck(s, alias), memoryCheck(s), diskCheck(s.diskMb)].filter(
93
- (c): c is DoctorCheck => c !== null
94
- );
136
+ return [
137
+ loadCheck(s, alias),
138
+ memoryCheck(s),
139
+ diskCheck(s.diskMb),
140
+ inodesCheck(s.inodesFreePct),
141
+ ].filter((c): c is DoctorCheck => c !== null);
95
142
  }
@@ -0,0 +1,205 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import macos from '../ansible/macos/policy.json';
4
+ import type { Check, Runner } from './types';
5
+
6
+ /** Read-only measurements of the manager's operating system and disk, from
7
+ * the floors `ansible/macos/policy.json` declares. Nothing a command printed
8
+ * is reported: only what was concluded from it.
9
+ */
10
+ export async function managerHealth(
11
+ run: Runner,
12
+ home = homedir()
13
+ ): Promise<Check[]> {
14
+ return [
15
+ await managerRelease(run),
16
+ await managerUpdates(run),
17
+ await managerDisk(run),
18
+ await containerWatchdog(run, home),
19
+ ];
20
+ }
21
+
22
+ /** The manager runs at least the macOS release the policy names. */
23
+ export async function managerRelease(run: Runner): Promise<Check> {
24
+ const id = 'manager:release';
25
+ const result = await run(['sw_vers', '-productVersion']);
26
+ const major = /^(\d+)(?:\.\d+)*$/.exec(result.stdout.trim())?.[1];
27
+ if (!result.ok || major === undefined)
28
+ return { id, status: 'skip', summary: 'MANAGER_RELEASE_UNOBSERVED' };
29
+ if (Number(major) >= macos.minimumMajorVersion)
30
+ return {
31
+ id,
32
+ status: 'pass',
33
+ summary: `The manager runs macOS ${major}, at least the required ${macos.minimumMajorVersion}.`,
34
+ };
35
+ return {
36
+ id,
37
+ status: 'fail',
38
+ summary: `MANAGER_RELEASE_UNSUPPORTED macOS ${major}, below ${macos.minimumMajorVersion}`,
39
+ remedy: {
40
+ action: `Update the manager to macOS ${macos.minimumMajorVersion} or later in System Settings > General > Software Update, then rerun fleet-sync.`,
41
+ },
42
+ };
43
+ }
44
+
45
+ /** Pending macOS updates warn, and so does one that waits for a restart.
46
+ * `--no-scan` reads the list macOS already has: nothing is downloaded.
47
+ */
48
+ export async function managerUpdates(run: Runner): Promise<Check> {
49
+ const id = 'manager:updates';
50
+ const result = await run(['softwareupdate', '--list', '--no-scan'], {
51
+ timeoutMs: 60_000,
52
+ });
53
+ const output = `${result.stdout}\n${result.stderr}`;
54
+ if (!result.ok)
55
+ return { id, status: 'skip', summary: 'MANAGER_UPDATES_UNOBSERVED' };
56
+ const pending = (output.match(/^\s*\* Label:/gm) ?? []).length;
57
+ const restart = /Action:\s*restart/i.test(output);
58
+ if (pending === 0)
59
+ return { id, status: 'pass', summary: 'No macOS update is pending.' };
60
+ return {
61
+ id,
62
+ status: 'warn',
63
+ summary: restart
64
+ ? `MANAGER_RESTART_PENDING ${pending} updates, at least one waits for a restart`
65
+ : `MANAGER_UPDATES_PENDING ${pending} updates`,
66
+ remedy: {
67
+ action:
68
+ 'Install the pending updates in System Settings > General > Software Update at a time no worker depends on the manager, then rerun fleet-sync.',
69
+ },
70
+ };
71
+ }
72
+
73
+ const MIB = 1024;
74
+ const GIB = 1024 ** 3;
75
+
76
+ /** Free space on the data volume, and the space Docker Desktop holds. */
77
+ export async function managerDisk(run: Runner): Promise<Check> {
78
+ const id = 'manager:disk';
79
+ const df = await run(['df', '-Pk', '/System/Volumes/Data']);
80
+ const kib = Number(df.stdout.split('\n')[1]?.trim().split(/\s+/)[3]);
81
+ if (!df.ok || !Number.isFinite(kib))
82
+ return { id, status: 'skip', summary: 'MANAGER_DISK_UNOBSERVED' };
83
+ const free = Math.floor(kib / MIB);
84
+ const docker = await dockerBytes(run);
85
+ const dockerGiB = docker === null ? null : docker / GIB;
86
+ const held =
87
+ dockerGiB === null ? '' : `; Docker holds ${dockerGiB.toFixed(1)} GiB`;
88
+ const remedy = {
89
+ action:
90
+ 'Free space on the manager: docker system prune removes unused images and build cache; empty large caches and downloads.',
91
+ };
92
+ if (free < macos.disk.failFreeMiB)
93
+ return {
94
+ id,
95
+ status: 'fail',
96
+ summary: `MANAGER_DISK_FULL ${free} MiB free${held}`,
97
+ remedy,
98
+ };
99
+ if (free < macos.disk.warnFreeMiB)
100
+ return {
101
+ id,
102
+ status: 'warn',
103
+ summary: `MANAGER_DISK_LOW ${free} MiB free${held}`,
104
+ remedy,
105
+ };
106
+ if (dockerGiB !== null && dockerGiB > macos.disk.dockerWarnGiB)
107
+ return {
108
+ id,
109
+ status: 'warn',
110
+ summary: `MANAGER_DOCKER_DISK_LARGE ${free} MiB free${held}`,
111
+ remedy,
112
+ };
113
+ return {
114
+ id,
115
+ status: 'pass',
116
+ summary: `${Math.floor(free / MIB)} GiB free on the data volume${held}.`,
117
+ };
118
+ }
119
+
120
+ const UNITS: Record<string, number> = {
121
+ B: 1,
122
+ kB: 1e3,
123
+ KB: 1e3,
124
+ MB: 1e6,
125
+ GB: 1e9,
126
+ TB: 1e12,
127
+ };
128
+
129
+ /** The bytes of images, containers, volumes and build cache, or null when
130
+ * Docker does not answer.
131
+ */
132
+ async function dockerBytes(run: Runner): Promise<number | null> {
133
+ const result = await run(
134
+ ['docker', 'system', 'df', '--format', '{{.Size}}'],
135
+ { timeoutMs: 30_000 }
136
+ );
137
+ if (!result.ok) return null;
138
+ let total = 0;
139
+ for (const line of result.stdout.split('\n')) {
140
+ const match = /^([\d.]+)\s*([kKMGT]?B)$/.exec(line.trim());
141
+ if (match) total += Number(match[1]) * (UNITS[match[2]!] ?? 0);
142
+ }
143
+ return total;
144
+ }
145
+
146
+ /** What the container watchdog remembers (ansible/monitoring/container-watchdog.py). */
147
+ type WatchdogMemory = Record<
148
+ string,
149
+ { restarts?: number[]; capped?: number | null }
150
+ >;
151
+
152
+ /** A container the watchdog stopped restarting is a restart storm: it fails,
153
+ * and one it restarted within the hour warns.
154
+ */
155
+ export async function containerWatchdog(
156
+ run: Runner,
157
+ home: string,
158
+ now = Date.now()
159
+ ): Promise<Check> {
160
+ const id = 'manager:container-watchdog';
161
+ const result = await run([
162
+ 'cat',
163
+ join(home, 'Library/Caches/dev-fleet/container-watchdog.json'),
164
+ ]);
165
+ let memory: WatchdogMemory;
166
+ try {
167
+ memory = JSON.parse(result.stdout) as WatchdogMemory;
168
+ } catch {
169
+ memory = {};
170
+ }
171
+ if (!result.ok || typeof memory !== 'object' || memory === null)
172
+ return { id, status: 'skip', summary: 'CONTAINER_WATCHDOG_UNOBSERVED' };
173
+ const hour = (time: unknown) =>
174
+ typeof time === 'number' && now / 1000 - time < 3600;
175
+ const names = Object.keys(memory).filter((name) =>
176
+ /^[A-Za-z0-9_.-]{1,128}$/.test(name)
177
+ );
178
+ const capped = names.filter((name) => hour(memory[name]?.capped));
179
+ const restarted = names.filter((name) =>
180
+ (memory[name]?.restarts ?? []).some(hour)
181
+ );
182
+ const remedy = {
183
+ action:
184
+ 'Read the container watchdog log (~/Library/Application Support/dev-fleet/container-watchdog/watchdog.log) and the container logs, fix the cause, then docker restart the container.',
185
+ };
186
+ if (capped.length)
187
+ return {
188
+ id,
189
+ status: 'fail',
190
+ summary: `CONTAINER_RESTART_STORM ${capped.join(' ')}`,
191
+ remedy,
192
+ };
193
+ if (restarted.length)
194
+ return {
195
+ id,
196
+ status: 'warn',
197
+ summary: `CONTAINER_RESTARTED_UNHEALTHY ${restarted.join(' ')}`,
198
+ remedy,
199
+ };
200
+ return {
201
+ id,
202
+ status: 'pass',
203
+ summary: 'No container stayed unhealthy in the last hour.',
204
+ };
205
+ }
@@ -6,7 +6,11 @@ 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';
@@ -102,7 +106,12 @@ export async function synchronizeManager(
102
106
  ok: false,
103
107
  applied: false,
104
108
  desiredRevision: input.revision,
105
- checks: tailnet.checks,
109
+ checks: [
110
+ ...tailnet.checks,
111
+ ...(tailnet.computers.some((peer) => peer.self)
112
+ ? [keyExpiryCheck(tailnet.computers.find((peer) => peer.self))]
113
+ : []),
114
+ ],
106
115
  // Printed for people: the stable public name, never a tailnet address.
107
116
  dashboardUrl: publicHubUrl(),
108
117
  nodes: [],
@@ -322,6 +331,7 @@ async function inspectManagerNode(
322
331
  else {
323
332
  checks.push(...(await inspectBootstrap(node, run, machine.os)));
324
333
  checks.push(...identityChecks(node, peer));
334
+ if (peer) checks.push(keyExpiryCheck(peer));
325
335
  }
326
336
  if (!peer || !peer.online)
327
337
  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 {