@crvouga/dev-fleet 0.1.0 → 0.3.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 (159) hide show
  1. package/README.md +265 -37
  2. package/agents/setup.md +42 -12
  3. package/ansible/macos/manager.yml +10 -7
  4. package/ansible/macos/policy.json +2 -10
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/README.md +86 -25
  7. package/ansible/monitoring/agent.yml +40 -2
  8. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  9. package/ansible/monitoring/lock.json +5 -0
  10. package/ansible/pyproject.toml +12 -0
  11. package/ansible/ubuntu/baseline.yml +1 -1
  12. package/ansible/ubuntu/disk-gc.py +336 -0
  13. package/ansible/ubuntu/policy.json +13 -3
  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/tailscale.yml +8 -5
  19. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  20. package/ansible/ubuntu/tasks/resources.yml +61 -1
  21. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  22. package/ansible/ubuntu/wip-guard.py +121 -0
  23. package/ansible/uv.lock +190 -0
  24. package/bootstrap/bootstrap.sh +25 -18
  25. package/bootstrap/doctor.ts +46 -1
  26. package/bootstrap/fleet-manager-run.sh +42 -15
  27. package/bootstrap/fleet-remote-bootstrap.sh +85 -12
  28. package/bootstrap/global-config.ts +64 -6
  29. package/bootstrap/install-launcher.sh +3 -0
  30. package/bootstrap/native-t3.ts +22 -11
  31. package/bootstrap/npm.ts +10 -2
  32. package/bootstrap/release-version.ts +157 -0
  33. package/cliproxy/README.md +3 -3
  34. package/cliproxy/scripts/accounts.py +216 -0
  35. package/cliproxy/scripts/checkpoint.py +29 -1
  36. package/cliproxy/scripts/cli.py +1 -1
  37. package/cliproxy/scripts/clientkeys.py +99 -0
  38. package/cliproxy/scripts/lease.py +80 -0
  39. package/cliproxy/scripts/ops +137 -25
  40. package/cliproxy/scripts/restore-verify +15 -0
  41. package/docs/accounts.md +154 -0
  42. package/docs/consumer-setup.md +408 -0
  43. package/docs/declared.md +91 -0
  44. package/docs/durability.md +26 -0
  45. package/docs/machine-setup.md +400 -0
  46. package/docs/operations.md +276 -0
  47. package/docs/releasing.md +103 -0
  48. package/docs/state.md +254 -0
  49. package/docs/topology.md +69 -0
  50. package/docs/ubuntu-sync.md +357 -0
  51. package/docs/worker-contract.md +67 -0
  52. package/examples/fleet.json +5 -0
  53. package/llms.txt +36 -0
  54. package/mise.lock +29 -0
  55. package/mise.toml +15 -2
  56. package/package.json +11 -2
  57. package/schema/fleet.schema.json +137 -15
  58. package/src/accounts/checks.ts +220 -0
  59. package/src/accounts/context.ts +76 -0
  60. package/src/accounts/propagate.ts +62 -0
  61. package/src/accounts/roster.ts +85 -0
  62. package/src/accounts/selection.ts +67 -0
  63. package/src/accounts/sessions.ts +120 -0
  64. package/src/agents/account-relay.ts +176 -26
  65. package/src/agents/fleet-verify.ts +9 -0
  66. package/src/agents/launch.ts +84 -5
  67. package/src/agents/native-verify.ts +293 -22
  68. package/src/agents/provider-errors.ts +9 -2
  69. package/src/agents/registry.ts +7 -1
  70. package/src/cli/agent-guide.ts +39 -5
  71. package/src/cli/app.ts +11 -4
  72. package/src/cli/cli.ts +7 -0
  73. package/src/cli/command-index.ts +7 -0
  74. package/src/cli/fleet-accounts.ts +253 -0
  75. package/src/cli/fleet-state.ts +499 -0
  76. package/src/cli/fleet-sync-accounts.ts +58 -0
  77. package/src/cli/fleet-sync-checkout.ts +56 -17
  78. package/src/cli/fleet-sync-enrollment.ts +19 -23
  79. package/src/cli/fleet-sync-forward.ts +1 -1
  80. package/src/cli/fleet-sync-manager.ts +84 -12
  81. package/src/cli/fleet-sync-monitoring.ts +5 -5
  82. package/src/cli/fleet-sync-output.ts +7 -3
  83. package/src/cli/fleet-sync-remote.ts +31 -18
  84. package/src/cli/fleet-sync-state.ts +86 -0
  85. package/src/cli/fleet-sync.ts +159 -57
  86. package/src/cli/fleet.ts +4 -2
  87. package/src/cli/managed-health.ts +4 -4
  88. package/src/cli/node-sync.ts +7 -0
  89. package/src/cli/program.ts +20 -4
  90. package/src/cli/project.ts +51 -0
  91. package/src/cli/register-fleet.ts +86 -1
  92. package/src/cli/registry.ts +41 -1
  93. package/src/cli/roster.ts +46 -30
  94. package/src/cli/settings.ts +15 -12
  95. package/src/cli/source-operations.ts +27 -3
  96. package/src/cli/tailnet.ts +2 -2
  97. package/src/definition.ts +116 -4
  98. package/src/diagnostics.ts +1 -1
  99. package/src/fleet-config.ts +105 -19
  100. package/src/ide/definition.ts +86 -0
  101. package/src/ide/ide.ts +43 -0
  102. package/src/ide/index.ts +5 -0
  103. package/src/ide/orchestrate.ts +50 -0
  104. package/src/ide/t3.ts +111 -0
  105. package/src/machine/source.ts +3 -3
  106. package/src/machine/state.ts +15 -27
  107. package/src/manager-sync.ts +9 -18
  108. package/src/monitoring/agent-evidence.ts +146 -0
  109. package/src/monitoring/apply.ts +15 -3
  110. package/src/monitoring/beszel.ts +11 -4
  111. package/src/monitoring/enroll.ts +2 -1
  112. package/src/monitoring/hub-access.ts +4 -3
  113. package/src/monitoring/hub-backup.ts +121 -0
  114. package/src/monitoring/hub-viewer.ts +8 -8
  115. package/src/monitoring/hub.ts +3 -3
  116. package/src/monitoring/manager-hub.ts +30 -14
  117. package/src/node-converge-linux.ts +30 -7
  118. package/src/package-info.ts +45 -0
  119. package/src/platform/ansible.ts +19 -15
  120. package/src/platform/darwin.ts +0 -13
  121. package/src/platform/fallback.ts +0 -8
  122. package/src/platform/linux.ts +0 -10
  123. package/src/platform/resource-verify.ts +8 -4
  124. package/src/platform/t3-policy.ts +33 -6
  125. package/src/platform/t3-service.ts +104 -0
  126. package/src/platform/types.ts +0 -4
  127. package/src/platform/ubuntu-audit.ts +5 -2
  128. package/src/platform/windows.ts +0 -14
  129. package/src/remote-bootstrap.ts +17 -19
  130. package/src/remote-entry.ts +15 -1
  131. package/src/repair-catalog.ts +1 -1
  132. package/src/runner.ts +1 -1
  133. package/src/service-auth.ts +15 -5
  134. package/src/state/bucket.ts +208 -0
  135. package/src/state/check.ts +74 -0
  136. package/src/state/connection.ts +92 -0
  137. package/src/state/enrollment.ts +45 -0
  138. package/src/state/legacy.ts +89 -0
  139. package/src/state/litestream.ts +143 -0
  140. package/src/state/operator.ts +20 -0
  141. package/src/state/paths.ts +40 -0
  142. package/src/state/roster.ts +62 -0
  143. package/src/state/store.ts +169 -0
  144. package/src/state/sync.ts +352 -0
  145. package/src/t3/accounts.ts +7 -1
  146. package/src/t3/models.ts +133 -0
  147. package/src/t3/pairing.ts +31 -0
  148. package/src/t3/profile.ts +203 -0
  149. package/src/t3/version.ts +119 -0
  150. package/src/t3-endpoint.ts +64 -8
  151. package/src/t3-linked-check.ts +24 -2
  152. package/src/toolchain-checks.ts +25 -0
  153. package/src/transport.ts +3 -2
  154. package/src/types.ts +4 -0
  155. package/src/worker-access-checks.ts +56 -3
  156. package/src/worker-host-checks.ts +41 -13
  157. package/src/workstation-checks.ts +1 -1
  158. package/src/index.ts +0 -6
  159. package/src/test-fixtures.ts +0 -36
@@ -0,0 +1,86 @@
1
+ import { FleetUsageError } from '../errors';
2
+ import { record } from '../config';
3
+
4
+ /** The IDEs dev-fleet supports. The support matrix in the README lists the
5
+ * same; enabling one on the fleet is `ides` in fleet.json, not new code.
6
+ */
7
+ export const SUPPORTED_IDES = ['t3'] as const;
8
+ export type IdeId = (typeof SUPPORTED_IDES)[number];
9
+
10
+ /** What `t3` in fleet.json declares for every machine's T3. */
11
+ export interface T3Definition {
12
+ /** Top-level T3 settings every machine holds the same: tool servers,
13
+ * projects, selected preferences. A key not named here stays local.
14
+ */
15
+ profile?: Record<string, unknown>;
16
+ /** Models T3 must offer for every account of a tool, by tool. */
17
+ requiredModels?: Record<string, string[]>;
18
+ }
19
+
20
+ const KEY = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
21
+ /** Settings another part of dev-fleet owns. */
22
+ const RESERVED = ['providerInstances'];
23
+
24
+ function invalid(detail: string): never {
25
+ throw new FleetUsageError(`Invalid fleet.json: ${detail}`);
26
+ }
27
+
28
+ /** The IDEs a fleet runs: those it names, or T3 alone. */
29
+ export const idesOf = (definition: { ides?: readonly IdeId[] }): IdeId[] => [
30
+ ...(definition.ides ?? ['t3']),
31
+ ];
32
+
33
+ export function parseIdes(value: unknown): IdeId[] {
34
+ if (
35
+ !Array.isArray(value) ||
36
+ value.length === 0 ||
37
+ new Set(value).size !== value.length ||
38
+ value.some((id) => !SUPPORTED_IDES.includes(id as IdeId))
39
+ )
40
+ invalid(`ides lists each of ${SUPPORTED_IDES.join(', ')} at most once.`);
41
+ return value as IdeId[];
42
+ }
43
+
44
+ export function parseT3(value: unknown): T3Definition {
45
+ if (
46
+ !record(value) ||
47
+ Object.keys(value).some(
48
+ (key) => !['profile', 'requiredModels'].includes(key)
49
+ )
50
+ )
51
+ invalid('t3 holds only profile and requiredModels.');
52
+ const result: T3Definition = {};
53
+ if (value['profile'] !== undefined) {
54
+ const profile = value['profile'];
55
+ if (
56
+ !record(profile) ||
57
+ Object.keys(profile).some(
58
+ (key) => !KEY.test(key) || RESERVED.includes(key)
59
+ ) ||
60
+ JSON.stringify(profile).length > 65_536
61
+ )
62
+ invalid(
63
+ 't3.profile names top-level T3 settings (not providerInstances) and stays under 64 KiB.'
64
+ );
65
+ result.profile = profile;
66
+ }
67
+ if (value['requiredModels'] !== undefined) {
68
+ const required = value['requiredModels'];
69
+ if (
70
+ !record(required) ||
71
+ Object.entries(required).some(
72
+ ([tool, models]) =>
73
+ !KEY.test(tool) ||
74
+ !Array.isArray(models) ||
75
+ models.some(
76
+ (model) =>
77
+ typeof model !== 'string' ||
78
+ !/^[A-Za-z0-9._:-]{1,128}$/.test(model)
79
+ )
80
+ )
81
+ )
82
+ invalid('t3.requiredModels lists model names by tool.');
83
+ result.requiredModels = required as Record<string, string[]>;
84
+ }
85
+ return result;
86
+ }
package/src/ide/ide.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { Check, FleetNode, Runner } from '../types';
2
+
3
+ /** What every supported IDE is asked for. An implementation owns its own
4
+ * install, service and policy (an Ansible role per IDE, with the common
5
+ * variable contract: version, checksum, policy, instances, profile) and is
6
+ * reached only through these calls, so the orchestration never names one.
7
+ */
8
+ export interface Ide {
9
+ /** The name the fleet configuration enables it by, under `ides`. */
10
+ readonly id: string;
11
+ /** The IDE's settings on this machine: provider instances from the account
12
+ * roster, the declared profile and the models it offers. Run on the manager
13
+ * and on every worker by the machine itself.
14
+ */
15
+ settings(context: MachineContext): Promise<Check[]>;
16
+ /** The manager's own IDE: its settings, its pinned version and its policy. */
17
+ manager(context: MachineContext): Promise<Check[]>;
18
+ /** What the manager measures of a worker's IDE: remote access, pairing and
19
+ * the version the worker runs.
20
+ */
21
+ worker(node: FleetNode, context: WorkerContext): Promise<Check[]>;
22
+ }
23
+
24
+ export interface MachineContext {
25
+ /** The node ID this machine is in the fleet. */
26
+ id: string;
27
+ /** The checkout this run applies. */
28
+ workspace: string;
29
+ apply: boolean;
30
+ run: Runner;
31
+ }
32
+
33
+ export interface WorkerContext {
34
+ run: Runner;
35
+ /** The manager's checkout of the revision this sync applies. */
36
+ workspace: string;
37
+ /** The checks this sync already reported for the worker. */
38
+ earlier: Check[];
39
+ /** Records what identifies the worker's IDE in the fleet state, the first
40
+ * time it is seen through a verified link.
41
+ */
42
+ remember?: (node: string, identity: string) => Promise<void>;
43
+ }
@@ -0,0 +1,5 @@
1
+ import type { Ide } from './ide';
2
+ import { t3Ide } from './t3';
3
+
4
+ /** Every IDE dev-fleet implements, by the ID fleet.json enables it with. */
5
+ export const IDES: Readonly<Record<string, Ide>> = { t3: t3Ide() };
@@ -0,0 +1,50 @@
1
+ import type { Check, FleetNode } from '../types';
2
+ import type { Ide, MachineContext, WorkerContext } from './ide';
3
+
4
+ /** The implementations the fleet enables, in the order it names them. An ID
5
+ * nobody implements is refused before anything runs.
6
+ */
7
+ export function enabled(
8
+ ids: readonly string[],
9
+ registry: Readonly<Record<string, Ide>>
10
+ ): Ide[] {
11
+ return ids.map((id) => {
12
+ const ide = registry[id];
13
+ if (!ide) throw new Error(`Unsupported IDE: ${id}`);
14
+ return ide;
15
+ });
16
+ }
17
+
18
+ export async function settingsChecks(
19
+ ides: Ide[],
20
+ context: MachineContext
21
+ ): Promise<Check[]> {
22
+ const checks: Check[] = [];
23
+ for (const ide of ides) checks.push(...(await ide.settings(context)));
24
+ return checks;
25
+ }
26
+
27
+ export async function managerChecks(
28
+ ides: Ide[],
29
+ context: MachineContext
30
+ ): Promise<Check[]> {
31
+ const checks: Check[] = [];
32
+ for (const ide of ides) checks.push(...(await ide.manager(context)));
33
+ return checks;
34
+ }
35
+
36
+ export async function workerChecks(
37
+ ides: Ide[],
38
+ node: FleetNode,
39
+ context: WorkerContext
40
+ ): Promise<Check[]> {
41
+ const checks: Check[] = [];
42
+ for (const ide of ides)
43
+ checks.push(
44
+ ...(await ide.worker(node, {
45
+ ...context,
46
+ earlier: [...context.earlier, ...checks],
47
+ }))
48
+ );
49
+ return checks;
50
+ }
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
+ }
@@ -5,7 +5,7 @@ import type { Runner } from '../types';
5
5
  import { ensurePrivateRoot } from './state';
6
6
  import { fleet } from '../fleet-config';
7
7
 
8
- const REPOSITORY = fleet.source.repository;
8
+ const repository = (): string => fleet.source.repository;
9
9
 
10
10
  /** Called after approval; local sources supply Git objects only, never dirty files. */
11
11
  export async function prepareSource(
@@ -40,7 +40,7 @@ async function prepareRemoteSource(
40
40
  '--no-checkout',
41
41
  '--origin',
42
42
  'origin',
43
- REPOSITORY,
43
+ repository(),
44
44
  source,
45
45
  ],
46
46
  { timeoutMs: 120_000 }
@@ -48,7 +48,7 @@ async function prepareRemoteSource(
48
48
  if (!cloned.ok) return null;
49
49
  origin = await run(['git', 'remote', 'get-url', 'origin'], { cwd: source });
50
50
  }
51
- if (!origin.ok || origin.stdout.trim() !== REPOSITORY) return null;
51
+ if (!origin.ok || origin.stdout.trim() !== repository()) return null;
52
52
  let found = await run(
53
53
  ['git', 'rev-parse', '--verify', `${revision}^{commit}`],
54
54
  { cwd: source }
@@ -25,14 +25,19 @@ export interface Enrollment {
25
25
  export const isRevision = (value: unknown): value is string =>
26
26
  typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
27
27
 
28
+ /** The manager's enrollment from the file that held it before it moved into
29
+ * the fleet state (src/state/enrollment.ts). Nothing writes this file now.
30
+ */
28
31
  export async function readEnrollment(root: string): Promise<Enrollment | null> {
29
32
  try {
30
33
  const path = join(root, 'enrollment.json');
31
34
  const info = await lstat(path);
32
35
  if (!info.isFile() || info.size > 4096) throw new Error();
33
- const data: unknown = JSON.parse(await readFile(path, 'utf8'));
34
- if (!validEnrollment(data)) throw new Error();
35
- return data as unknown as Enrollment;
36
+ const enrollment = parseEnrollment(
37
+ JSON.parse(await readFile(path, 'utf8'))
38
+ );
39
+ if (!enrollment) throw new Error();
40
+ return enrollment;
36
41
  } catch (error) {
37
42
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
38
43
  throw new FleetUsageError(
@@ -41,24 +46,6 @@ export async function readEnrollment(root: string): Promise<Enrollment | null> {
41
46
  }
42
47
  }
43
48
 
44
- export async function saveEnrollment(
45
- root: string,
46
- state: Enrollment
47
- ): Promise<void> {
48
- await mkdir(root, { recursive: true, mode: 0o700 });
49
- const path = join(root, 'enrollment.json');
50
- const temporary = `${path}.${randomUUID()}.tmp`;
51
- try {
52
- await writeFile(temporary, JSON.stringify(state) + '\n', {
53
- mode: 0o600,
54
- flag: 'wx',
55
- });
56
- await rename(temporary, path);
57
- } finally {
58
- await rm(temporary, { force: true });
59
- }
60
- }
61
-
62
49
  /** A lock directory with no readable owner is only abandoned after this long,
63
50
  * so a run that has just created it but not yet written `owner.json` is safe. */
64
51
  const OWNERLESS_LOCK_GRACE_MS = 60_000;
@@ -169,17 +156,18 @@ export async function ensurePrivateRoot(root: string): Promise<void> {
169
156
  }
170
157
  }
171
158
 
172
- function validEnrollment(data: unknown): boolean {
173
- if (!record(data) || data['version'] !== 1) return false;
159
+ /** A valid enrollment record, or null. */
160
+ export function parseEnrollment(data: unknown): Enrollment | null {
161
+ if (!record(data) || data['version'] !== 1) return null;
174
162
  if (
175
163
  typeof data['id'] !== 'string' ||
176
164
  !/^[a-z][a-z0-9-]{0,62}$/.test(data['id'])
177
165
  )
178
- return false;
179
- return (
180
- isRevision(data['approvedRevision']) &&
166
+ return null;
167
+ return isRevision(data['approvedRevision']) &&
181
168
  ['appliedRevision', 'previousRevision'].every(
182
169
  (key) => data[key] === undefined || isRevision(data[key])
183
170
  )
184
- );
171
+ ? (data as unknown as Enrollment)
172
+ : null;
185
173
  }
@@ -10,7 +10,7 @@ import { discoverTailnet, type TailnetComputer } from './tailscale';
10
10
  import { matchComputers } from './tailnet-matching';
11
11
  import { sshArgs, sshFailure } from './ssh';
12
12
  import type { FleetDefinition, MachineDefinition } from './definition';
13
- import { PUBLIC_HUB_URL } from './monitoring/beszel';
13
+ import { publicHubUrl } from './monitoring/beszel';
14
14
  import {
15
15
  convergeable,
16
16
  evaluatePlatform,
@@ -104,7 +104,7 @@ export async function synchronizeManager(
104
104
  desiredRevision: input.revision,
105
105
  checks: tailnet.checks,
106
106
  // Printed for people: the stable public name, never a tailnet address.
107
- dashboardUrl: PUBLIC_HUB_URL,
107
+ dashboardUrl: publicHubUrl(),
108
108
  nodes: [],
109
109
  candidates: tailnet.computers.filter(
110
110
  (peer) => !peer.self && !matches.get(peer.id)?.length
@@ -179,7 +179,12 @@ async function applyNodes(
179
179
  deploy: DeployNode
180
180
  ): Promise<void> {
181
181
  const workers = report.nodes.filter((node) => node.role !== 'manager');
182
- const canary = workers.find((node) => node.node === input.canary);
182
+ // A revision new to the fleet is proven on one worker first. When the run
183
+ // selects only later workers, the first selected one proves it instead:
184
+ // narrowing a run never skips the canary.
185
+ const canary = input.canary
186
+ ? (workers.find((node) => node.node === input.canary) ?? workers[0])
187
+ : undefined;
183
188
  let blocked = report.checks.some(blocking);
184
189
  const step = async (result: ManagerNode) => {
185
190
  const deployable = mayDeploy(result, input.nodes);
@@ -380,21 +385,7 @@ function mayDeploy(result: ManagerNode, nodes: FleetNode[]): boolean {
380
385
  */
381
386
  export function blocksPromotion(check: Check): boolean {
382
387
  if (!blocking(check)) return false;
383
- if (check.id === 't3:service' && T3_MAINTENANCE.has(check.summary))
384
- return false;
385
- return !/^(worker:|beszel:|provider:|agent:|proxy:|t3:end-to-end$|t3:paired-access$|t3:policy-loaded$|ubuntu:boot-policy$)/.test(
388
+ return !/^(worker:|beszel:|provider:|agent:|proxy:|t3:end-to-end$|t3:paired-access$|t3:policy-loaded$|t3:running-version$|ubuntu:boot-policy$)/.test(
386
389
  check.id
387
390
  );
388
391
  }
389
-
390
- /** Native T3 outcomes that need an operator maintenance window (an active
391
- * listener or running release must never be killed by routine sync). They are
392
- * reported as failures but do not block independent OS policy on other nodes.
393
- */
394
- const T3_MAINTENANCE = new Set([
395
- 'T3_LISTENER_CONFLICT',
396
- 'T3_ACTIVE_LISTENER_MIGRATION_REQUIRED',
397
- 'T3_MAINTENANCE_REQUIRED',
398
- 'T3_POLICY_RESTART_PENDING',
399
- 'T3_DESKTOP_MIGRATION_REQUIRED',
400
- ]);
@@ -0,0 +1,146 @@
1
+ import type { Check, Runner } from '../types';
2
+
3
+ /** Where installing the agent writes: the pinned build and its state. */
4
+ export const AGENT_PATHS = ['/usr/local/lib', '/var/lib'] as const;
5
+ /** Room for the archive, the extracted build and the agent's state. */
6
+ export const AGENT_FREE_KIB = 256 * 1024;
7
+
8
+ /** Measures, before anything is installed, that each path the agent writes
9
+ * to has room. A full disk is named as such instead of surfacing later as an
10
+ * incomplete installation.
11
+ */
12
+ export async function agentDisk(run: Runner): Promise<Check> {
13
+ const result = await run(['df', '-Pk', ...AGENT_PATHS], {
14
+ timeoutMs: 10_000,
15
+ });
16
+ const free = result.ok ? available(result.stdout) : null;
17
+ if (!free || free.length !== AGENT_PATHS.length)
18
+ return {
19
+ id: 'beszel:disk',
20
+ status: 'fail',
21
+ summary: 'BESZEL_DISK_UNVERIFIED',
22
+ remedy: {
23
+ action: `Free space under ${AGENT_PATHS.join(' and ')} could not be measured. Check that df works for the fleet user, then rerun fleet-sync. Nothing was installed.`,
24
+ },
25
+ };
26
+ const least = Math.min(...free);
27
+ if (least < AGENT_FREE_KIB)
28
+ return {
29
+ id: 'beszel:disk',
30
+ status: 'fail',
31
+ summary: 'BESZEL_DISK_FULL',
32
+ remedy: {
33
+ action: `The monitoring agent needs ${AGENT_FREE_KIB / 1024} MiB free under each of ${AGENT_PATHS.join(' and ')}; the fullest has ${Math.floor(least / 1024)} MiB. Free space on the worker, then rerun fleet-sync. Nothing was installed and the running agent was left as it was.`,
34
+ },
35
+ };
36
+ return {
37
+ id: 'beszel:disk',
38
+ status: 'pass',
39
+ summary: `At least ${Math.floor(least / 1024)} MiB free where the agent is installed.`,
40
+ };
41
+ }
42
+
43
+ /** The "Available" column of POSIX df output, one value per path. */
44
+ function available(output: string): number[] | null {
45
+ const rows = output.trim().split('\n').slice(1);
46
+ const values = rows.map((row) => Number(row.trim().split(/\s+/)[3]));
47
+ return values.length && values.every(Number.isSafeInteger) ? values : null;
48
+ }
49
+
50
+ /** What the pinned agent (Beszel 0.21.1) logs about its connection to the
51
+ * hub, from its source:
52
+ * - `agent/connection_manager.go` logs "WebSocket connected" once the hub
53
+ * proved its key;
54
+ * - the hub answers an unknown token with 401 (`internal/hub/agent_connect.go`),
55
+ * which the WebSocket library reports as "unexpected status code: 401";
56
+ * - a hub whose key is not the one the agent was given fails
57
+ * `verifySignature`: "invalid signature - check KEY value";
58
+ * - a hub that is down behind its private route answers 502, 503 or 504.
59
+ * Only these fixed texts are matched; nothing of the log is reported.
60
+ */
61
+ const OUTCOMES: { pattern: RegExp; check: Omit<Check, 'id'> }[] = [
62
+ {
63
+ pattern: /WebSocket connected/,
64
+ check: {
65
+ status: 'pass',
66
+ summary: 'The agent is connected to the hub, which proved its key.',
67
+ },
68
+ },
69
+ {
70
+ pattern: /unexpected status code: 401\b/,
71
+ check: {
72
+ status: 'fail',
73
+ summary: 'BESZEL_AGENT_TOKEN_REJECTED',
74
+ remedy: {
75
+ action:
76
+ "The hub refused this worker's agent token. Rerun fleet-sync from the manager so the worker's enrollment is resumed and its token delivered again. Never register a second record for the worker.",
77
+ },
78
+ },
79
+ },
80
+ {
81
+ pattern: /invalid signature - check KEY value/,
82
+ check: {
83
+ status: 'fail',
84
+ summary: 'BESZEL_AGENT_KEY_REJECTED',
85
+ remedy: {
86
+ action:
87
+ "The hub the agent reached did not prove the key it was given. Make sure the worker's agent points at the manager's hub, then rerun fleet-sync from the manager so the hub's key is delivered again.",
88
+ },
89
+ },
90
+ },
91
+ {
92
+ pattern: /unexpected status code: 50[234]\b/,
93
+ check: {
94
+ status: 'warn',
95
+ summary: 'BESZEL_HUB_UNAVAILABLE_TO_AGENT',
96
+ remedy: {
97
+ action:
98
+ 'The private route answered but the hub behind it did not. Keep the manager awake with Docker Desktop running and rerun fleet-sync there; the agent reconnects by itself.',
99
+ },
100
+ },
101
+ },
102
+ ];
103
+
104
+ /** The agent's newest connection outcome, read from its own log. The log
105
+ * holds host names, so only the outcome is reported.
106
+ */
107
+ export async function agentConnection(run: Runner): Promise<Check> {
108
+ const result = await run(
109
+ [
110
+ 'sudo',
111
+ '-n',
112
+ 'journalctl',
113
+ '--unit=beszel-agent.service',
114
+ '--output=cat',
115
+ '--no-pager',
116
+ '--lines=200',
117
+ ],
118
+ { timeoutMs: 10_000 }
119
+ );
120
+ if (!result.ok)
121
+ return {
122
+ id: 'beszel:hub-connection',
123
+ status: 'skip',
124
+ summary: 'BESZEL_AGENT_LOG_UNAVAILABLE',
125
+ remedy: {
126
+ action:
127
+ "The agent's log could not be read, so how the hub answered it is unknown. The hub's own evidence in fleet-status --workers still applies.",
128
+ },
129
+ };
130
+ return { id: 'beszel:hub-connection', ...newestOutcome(result.stdout) };
131
+ }
132
+
133
+ function newestOutcome(log: string): Omit<Check, 'id'> {
134
+ const lines = log.split('\n').reverse();
135
+ for (const line of lines)
136
+ for (const outcome of OUTCOMES)
137
+ if (outcome.pattern.test(line)) return outcome.check;
138
+ return {
139
+ status: 'skip',
140
+ summary: 'BESZEL_AGENT_CONNECTION_PENDING',
141
+ remedy: {
142
+ action:
143
+ 'The agent has not reached the hub yet. The next fleet-sync, or fleet-status --workers, reports whether it did.',
144
+ },
145
+ };
146
+ }
@@ -6,13 +6,15 @@ import { hubUrl, roleOf } from '../topology';
6
6
  import { record } from '../config';
7
7
  import { isRevision } from '../machine/state';
8
8
  import { privateHub } from './beszel';
9
+ import { agentConnection, agentDisk } from './agent-evidence';
9
10
  import { runLockedCommand as runCommand } from '../runner';
10
11
  import type { Check, Runner } from '../types';
11
12
 
12
13
  export async function applyAgent(
13
14
  value: unknown,
14
15
  root: string,
15
- run: Runner
16
+ run: Runner,
17
+ options: { receipts?: string } = {}
16
18
  ): Promise<Check[]> {
17
19
  const request = validateRequest(value);
18
20
  const definition = fleet;
@@ -32,7 +34,7 @@ export async function applyAgent(
32
34
  // Agents connect only to the manager's private hub URL.
33
35
  if (privateHub(request.hubUrl) !== privateHub(hubUrl(definition)))
34
36
  throw new Error('BESZEL_HUB_MISMATCH');
35
- return installAgent(request, root, run);
37
+ return installAgent(request, root, run, options.receipts);
36
38
  }
37
39
 
38
40
  /** Workers (Linux) install a hardened systemd unit. The macOS manager's own
@@ -42,8 +44,12 @@ export async function applyAgent(
42
44
  async function installAgent(
43
45
  request: Request,
44
46
  root: string,
45
- run: Runner
47
+ run: Runner,
48
+ receipts: string | undefined
46
49
  ): Promise<Check[]> {
50
+ // Measured before anything changes: a full disk is named, not guessed at.
51
+ const disk = await agentDisk(run);
52
+ if (disk.status !== 'pass') return [disk];
47
53
  const monitoring = resolve(root, 'ansible/monitoring');
48
54
  const result = await runAnsibleWithReceipt(
49
55
  run,
@@ -62,10 +68,12 @@ async function installAgent(
62
68
  name: 'beszel-agent',
63
69
  sources: [monitoring],
64
70
  verify: () => agentActive(run),
71
+ ...(receipts ? { directory: receipts } : {}),
65
72
  }
66
73
  );
67
74
  if (!result.ok)
68
75
  return [
76
+ disk,
69
77
  {
70
78
  id: 'beszel:installation',
71
79
  status: 'fail',
@@ -78,6 +86,7 @@ async function installAgent(
78
86
  ];
79
87
  const ready = await agentActive(run);
80
88
  return [
89
+ disk,
81
90
  {
82
91
  id: 'beszel:service',
83
92
  status: ready ? 'pass' : 'fail',
@@ -85,6 +94,9 @@ async function installAgent(
85
94
  ? 'Native Beszel service is enabled and active; hub metrics remain a separate gate.'
86
95
  : 'BESZEL_SERVICE_STOPPED',
87
96
  },
97
+ // How the hub answered the agent, from the agent's side: a rejected token
98
+ // or key is told apart from a hub that is merely down.
99
+ ...(ready ? [await agentConnection(run)] : []),
88
100
  ];
89
101
  }
90
102
  async function agentActive(run: Runner): Promise<boolean> {