@crvouga/dev-fleet 0.2.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 (129) hide show
  1. package/README.md +50 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/manager.yml +3 -0
  4. package/ansible/macos/policy.json +11 -1
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/compose.yml +10 -0
  7. package/ansible/monitoring/container-watchdog.py +118 -0
  8. package/ansible/monitoring/hub-darwin.yml +146 -0
  9. package/ansible/net/remote-probe.sh +5 -0
  10. package/ansible/ubuntu/baseline.yml +2 -0
  11. package/ansible/ubuntu/disk-gc.py +25 -3
  12. package/ansible/ubuntu/disk-guard.py +24 -5
  13. package/ansible/ubuntu/policy.json +51 -1
  14. package/ansible/ubuntu/t3-desktop.yml +19 -0
  15. package/ansible/ubuntu/t3-health.sh +93 -22
  16. package/ansible/ubuntu/t3-idle.py +42 -0
  17. package/ansible/ubuntu/t3-policy.yml +4 -11
  18. package/ansible/ubuntu/tasks/browser.yml +56 -0
  19. package/ansible/ubuntu/tasks/docker.yml +52 -0
  20. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  21. package/ansible/ubuntu/tasks/resources.yml +2 -2
  22. package/ansible/ubuntu/tasks/services.yml +13 -0
  23. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  24. package/ansible/ubuntu/wip-guard.py +121 -0
  25. package/bootstrap/doctor.ts +32 -6
  26. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  27. package/bootstrap/native-t3.ts +22 -11
  28. package/bootstrap/setup.ts +23 -8
  29. package/bootstrap/worker-tools.ts +118 -0
  30. package/cliproxy/README.md +4 -4
  31. package/cliproxy/compose.yaml +4 -0
  32. package/cliproxy/scripts/accounts.py +216 -0
  33. package/cliproxy/scripts/checkpoint.py +30 -2
  34. package/cliproxy/scripts/cli.py +1 -1
  35. package/cliproxy/scripts/clientkeys.py +99 -0
  36. package/cliproxy/scripts/lease.py +80 -0
  37. package/cliproxy/scripts/ops +137 -25
  38. package/cliproxy/scripts/restore-verify +15 -0
  39. package/docs/accounts.md +154 -0
  40. package/docs/authentication.md +90 -0
  41. package/docs/declared.md +5 -1
  42. package/docs/durability.md +26 -0
  43. package/docs/machine-setup.md +26 -15
  44. package/docs/operations.md +87 -31
  45. package/docs/runbooks.md +380 -0
  46. package/docs/state.md +13 -7
  47. package/docs/topology.md +2 -0
  48. package/docs/ubuntu-sync.md +35 -28
  49. package/examples/fleet.json +19 -1
  50. package/llms.txt +3 -0
  51. package/mise.toml +10 -1
  52. package/package.json +2 -2
  53. package/schema/check-ids.json +522 -0
  54. package/schema/fleet.schema.json +244 -1
  55. package/schema/output/check.schema.json +23 -0
  56. package/schema/output/error.schema.json +14 -0
  57. package/schema/output/fleet-report.schema.json +79 -0
  58. package/schema/output/fleet-sync.schema.json +112 -0
  59. package/schema/output/report.schema.json +38 -0
  60. package/src/accounts/checks.ts +220 -0
  61. package/src/accounts/context.ts +76 -0
  62. package/src/accounts/propagate.ts +62 -0
  63. package/src/accounts/roster.ts +85 -0
  64. package/src/accounts/selection.ts +67 -0
  65. package/src/accounts/sessions.ts +120 -0
  66. package/src/agents/account-relay.ts +176 -26
  67. package/src/agents/launch.ts +84 -5
  68. package/src/agents/native-verify.ts +128 -2
  69. package/src/auth-checks.ts +3 -0
  70. package/src/cli/agent-guide.ts +5 -3
  71. package/src/cli/fleet-accounts.ts +253 -0
  72. package/src/cli/fleet-report.ts +126 -0
  73. package/src/cli/fleet-sync-accounts.ts +58 -0
  74. package/src/cli/fleet-sync-manager.ts +31 -4
  75. package/src/cli/fleet-sync-output.ts +3 -2
  76. package/src/cli/fleet-sync-plan.ts +200 -0
  77. package/src/cli/fleet-sync-remote.ts +23 -14
  78. package/src/cli/fleet-sync-schedule.ts +142 -35
  79. package/src/cli/fleet-sync.ts +126 -8
  80. package/src/cli/fleet-watch.ts +198 -0
  81. package/src/cli/fleet.ts +4 -0
  82. package/src/cli/register-fleet.ts +86 -0
  83. package/src/cli/registry.ts +26 -0
  84. package/src/cli/schedule-logs.ts +36 -0
  85. package/src/clock.ts +63 -0
  86. package/src/definition.ts +226 -7
  87. package/src/git-identity.ts +160 -0
  88. package/src/ide/definition.ts +86 -0
  89. package/src/ide/ide.ts +43 -0
  90. package/src/ide/index.ts +5 -0
  91. package/src/ide/orchestrate.ts +50 -0
  92. package/src/ide/t3.ts +111 -0
  93. package/src/machine-health.ts +51 -4
  94. package/src/manager-health.ts +205 -0
  95. package/src/manager-sync.ts +13 -17
  96. package/src/monitoring/hub-backup.ts +63 -17
  97. package/src/monitoring/manager-hub.ts +44 -2
  98. package/src/node-converge-linux.ts +44 -12
  99. package/src/notify.ts +239 -0
  100. package/src/platform/darwin.ts +0 -13
  101. package/src/platform/fallback.ts +0 -8
  102. package/src/platform/linux.ts +0 -10
  103. package/src/platform/t3-policy.ts +33 -6
  104. package/src/platform/t3-service.ts +104 -0
  105. package/src/platform/types.ts +0 -4
  106. package/src/platform/windows.ts +0 -14
  107. package/src/remote-bootstrap.ts +87 -25
  108. package/src/remote-entry.ts +15 -1
  109. package/src/remote-operation.ts +213 -0
  110. package/src/schedule-check.ts +40 -0
  111. package/src/secrets.ts +26 -8
  112. package/src/service-auth.ts +15 -5
  113. package/src/ssh.ts +10 -2
  114. package/src/state/history.ts +172 -0
  115. package/src/state/store.ts +11 -4
  116. package/src/t3/accounts.ts +7 -1
  117. package/src/t3/models.ts +133 -0
  118. package/src/t3/pairing.ts +31 -0
  119. package/src/t3/profile.ts +203 -0
  120. package/src/t3/version.ts +119 -0
  121. package/src/t3-linked-check.ts +1 -1
  122. package/src/tailscale.ts +51 -0
  123. package/src/toolchain-checks.ts +8 -3
  124. package/src/transport.ts +3 -2
  125. package/src/worker-capability-checks.ts +167 -0
  126. package/src/worker-capacity.ts +19 -7
  127. package/src/worker-host-checks.ts +68 -13
  128. package/src/worker-policy.ts +33 -0
  129. package/src/worker-readiness.ts +10 -1
package/src/ide/t3.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ import type { Check, FleetNode } from '../types';
4
+ import type { Ide, MachineContext, WorkerContext } from './ide';
5
+ import { fleet } from '../fleet-config';
6
+ import { machineProviders } from '../t3/providers';
7
+ import { providerNames } from '../t3/names-check';
8
+ import { profileCheck } from '../t3/profile';
9
+ import { modelsCheck } from '../t3/models';
10
+ import { proxyAccounts, type Roster } from '../t3/accounts';
11
+ import {
12
+ MACOS_POLICY,
13
+ managerVersion,
14
+ updatesCheck,
15
+ workerVersion,
16
+ } from '../t3/version';
17
+ import { verifyT3Endpoint } from '../t3-endpoint';
18
+ import { observedIdentity, verifyT3Linked } from '../t3-linked-check';
19
+ import { T3_RESTARTED_SUMMARY } from '../platform/t3-policy';
20
+ import { ansibleCommand } from '../platform/ansible';
21
+
22
+ /** Where T3 keeps its data on this machine. */
23
+ const t3Home = (): string =>
24
+ resolve(process.env['T3CODE_HOME'] ?? join(homedir(), '.t3'));
25
+
26
+ /** What the T3 checks read besides the machine: injected by scenarios. */
27
+ export interface T3Inputs {
28
+ t3Home?: string;
29
+ roster?: Roster;
30
+ profile?: Record<string, unknown>;
31
+ requiredModels?: Record<string, string[]>;
32
+ policyPath?: string;
33
+ }
34
+
35
+ /** T3 Code, the first supported IDE. Its install, service and policy on
36
+ * workers are the `ansible/ubuntu/t3*` playbooks and on the manager
37
+ * `ansible/macos/t3-policy.yml`; everything else is reached from here.
38
+ */
39
+ export function t3Ide(inputs: T3Inputs = {}): Ide {
40
+ const home = () => inputs.t3Home ?? t3Home();
41
+ const settings = async (context: MachineContext): Promise<Check[]> => {
42
+ const required = inputs.requiredModels ?? fleet.t3?.requiredModels;
43
+ const options = {
44
+ t3Home: home(),
45
+ ...(inputs.roster ? { roster: inputs.roster } : {}),
46
+ };
47
+ return [
48
+ await machineProviders(
49
+ context.id,
50
+ context.workspace,
51
+ context.apply,
52
+ options
53
+ ),
54
+ await providerNames(
55
+ context.id,
56
+ context.workspace,
57
+ context.apply,
58
+ options
59
+ ),
60
+ await profileCheck(
61
+ join(home(), 'userdata/settings.json'),
62
+ inputs.profile ?? fleet.t3?.profile ?? {},
63
+ { apply: context.apply }
64
+ ),
65
+ await modelsCheck({
66
+ t3Home: home(),
67
+ roster: inputs.roster ?? proxyAccounts,
68
+ ...(required ? { required } : {}),
69
+ }),
70
+ ];
71
+ };
72
+ return {
73
+ id: 't3',
74
+ settings,
75
+ async manager(context) {
76
+ return [
77
+ ...(await settings(context)),
78
+ await managerVersion(home(), context.workspace),
79
+ await updatesCheck(
80
+ inputs.policyPath ?? MACOS_POLICY,
81
+ `cd ${context.workspace} && ${ansibleCommand().join(' ')} -K ansible/macos/manager.yml`
82
+ ),
83
+ ];
84
+ },
85
+ async worker(node: FleetNode, context: WorkerContext) {
86
+ const checks = [...(await verifyT3Endpoint(node, context.run))];
87
+ // A worker T3 restarted by this sync (idle policy heal) needs a minute
88
+ // or two before the manager's link reconnects.
89
+ const restarted = context.earlier.some(
90
+ (check) => check.summary === T3_RESTARTED_SUMMARY
91
+ );
92
+ const linked = await verifyT3Linked(node, context.run, {
93
+ reconnectMs: restarted ? 180_000 : 0,
94
+ });
95
+ checks.push(linked);
96
+ if (
97
+ linked.status !== 'fail' &&
98
+ !node.t3EnvironmentId &&
99
+ context.remember
100
+ ) {
101
+ const identity = await observedIdentity(node, context.run).catch(
102
+ () => undefined
103
+ );
104
+ if (identity)
105
+ await context.remember(node.id, identity).catch(() => undefined);
106
+ }
107
+ checks.push(await workerVersion(node, context.run, context.workspace));
108
+ return checks;
109
+ },
110
+ };
111
+ }
@@ -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({
@@ -385,21 +395,7 @@ function mayDeploy(result: ManagerNode, nodes: FleetNode[]): boolean {
385
395
  */
386
396
  export function blocksPromotion(check: Check): boolean {
387
397
  if (!blocking(check)) return false;
388
- if (check.id === 't3:service' && T3_MAINTENANCE.has(check.summary))
389
- return false;
390
- return !/^(worker:|beszel:|provider:|agent:|proxy:|t3:end-to-end$|t3:paired-access$|t3:policy-loaded$|ubuntu:boot-policy$)/.test(
398
+ return !/^(worker:|beszel:|provider:|agent:|proxy:|t3:end-to-end$|t3:paired-access$|t3:policy-loaded$|t3:running-version$|ubuntu:boot-policy$)/.test(
391
399
  check.id
392
400
  );
393
401
  }
394
-
395
- /** Native T3 outcomes that need an operator maintenance window (an active
396
- * listener or running release must never be killed by routine sync). They are
397
- * reported as failures but do not block independent OS policy on other nodes.
398
- */
399
- const T3_MAINTENANCE = new Set([
400
- 'T3_LISTENER_CONFLICT',
401
- 'T3_ACTIVE_LISTENER_MIGRATION_REQUIRED',
402
- 'T3_MAINTENANCE_REQUIRED',
403
- 'T3_POLICY_RESTART_PENDING',
404
- 'T3_DESKTOP_MIGRATION_REQUIRED',
405
- ]);
@@ -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