@crvouga/dev-fleet 0.1.0 → 0.2.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 (110) hide show
  1. package/README.md +237 -37
  2. package/agents/setup.md +42 -12
  3. package/ansible/macos/manager.yml +7 -7
  4. package/ansible/macos/policy.json +2 -10
  5. package/ansible/monitoring/README.md +86 -25
  6. package/ansible/monitoring/agent.yml +40 -2
  7. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  8. package/ansible/monitoring/lock.json +5 -0
  9. package/ansible/pyproject.toml +12 -0
  10. package/ansible/ubuntu/baseline.yml +1 -1
  11. package/ansible/ubuntu/disk-gc.py +336 -0
  12. package/ansible/ubuntu/policy.json +12 -3
  13. package/ansible/ubuntu/tailscale.yml +8 -5
  14. package/ansible/ubuntu/tasks/resources.yml +61 -1
  15. package/ansible/uv.lock +190 -0
  16. package/bootstrap/bootstrap.sh +25 -18
  17. package/bootstrap/doctor.ts +46 -1
  18. package/bootstrap/fleet-manager-run.sh +42 -15
  19. package/bootstrap/fleet-remote-bootstrap.sh +85 -12
  20. package/bootstrap/global-config.ts +64 -6
  21. package/bootstrap/install-launcher.sh +3 -0
  22. package/bootstrap/npm.ts +10 -2
  23. package/bootstrap/release-version.ts +157 -0
  24. package/docs/consumer-setup.md +408 -0
  25. package/docs/declared.md +89 -0
  26. package/docs/machine-setup.md +399 -0
  27. package/docs/operations.md +276 -0
  28. package/docs/releasing.md +103 -0
  29. package/docs/state.md +254 -0
  30. package/docs/topology.md +67 -0
  31. package/docs/ubuntu-sync.md +351 -0
  32. package/docs/worker-contract.md +67 -0
  33. package/examples/fleet.json +5 -0
  34. package/llms.txt +35 -0
  35. package/mise.lock +29 -0
  36. package/mise.toml +12 -1
  37. package/package.json +11 -2
  38. package/schema/fleet.schema.json +71 -15
  39. package/src/agents/fleet-verify.ts +9 -0
  40. package/src/agents/native-verify.ts +167 -22
  41. package/src/agents/provider-errors.ts +9 -2
  42. package/src/agents/registry.ts +7 -1
  43. package/src/cli/agent-guide.ts +39 -5
  44. package/src/cli/app.ts +11 -4
  45. package/src/cli/cli.ts +7 -0
  46. package/src/cli/command-index.ts +7 -0
  47. package/src/cli/fleet-state.ts +499 -0
  48. package/src/cli/fleet-sync-checkout.ts +56 -17
  49. package/src/cli/fleet-sync-enrollment.ts +19 -23
  50. package/src/cli/fleet-sync-forward.ts +1 -1
  51. package/src/cli/fleet-sync-manager.ts +64 -8
  52. package/src/cli/fleet-sync-monitoring.ts +5 -5
  53. package/src/cli/fleet-sync-output.ts +4 -1
  54. package/src/cli/fleet-sync-remote.ts +11 -5
  55. package/src/cli/fleet-sync-state.ts +86 -0
  56. package/src/cli/fleet-sync.ts +123 -57
  57. package/src/cli/fleet.ts +4 -2
  58. package/src/cli/managed-health.ts +4 -4
  59. package/src/cli/node-sync.ts +7 -0
  60. package/src/cli/program.ts +20 -4
  61. package/src/cli/project.ts +51 -0
  62. package/src/cli/register-fleet.ts +51 -1
  63. package/src/cli/registry.ts +28 -1
  64. package/src/cli/roster.ts +46 -30
  65. package/src/cli/settings.ts +15 -12
  66. package/src/cli/source-operations.ts +27 -3
  67. package/src/cli/tailnet.ts +2 -2
  68. package/src/definition.ts +31 -1
  69. package/src/diagnostics.ts +1 -1
  70. package/src/fleet-config.ts +105 -19
  71. package/src/machine/source.ts +3 -3
  72. package/src/machine/state.ts +15 -27
  73. package/src/manager-sync.ts +8 -3
  74. package/src/monitoring/agent-evidence.ts +146 -0
  75. package/src/monitoring/apply.ts +15 -3
  76. package/src/monitoring/beszel.ts +11 -4
  77. package/src/monitoring/enroll.ts +2 -1
  78. package/src/monitoring/hub-access.ts +4 -3
  79. package/src/monitoring/hub-backup.ts +121 -0
  80. package/src/monitoring/hub-viewer.ts +8 -8
  81. package/src/monitoring/hub.ts +3 -3
  82. package/src/monitoring/manager-hub.ts +30 -14
  83. package/src/node-converge-linux.ts +2 -0
  84. package/src/package-info.ts +45 -0
  85. package/src/platform/ansible.ts +19 -15
  86. package/src/platform/resource-verify.ts +8 -4
  87. package/src/platform/ubuntu-audit.ts +5 -2
  88. package/src/remote-bootstrap.ts +17 -19
  89. package/src/repair-catalog.ts +1 -1
  90. package/src/runner.ts +1 -1
  91. package/src/state/bucket.ts +208 -0
  92. package/src/state/check.ts +74 -0
  93. package/src/state/connection.ts +92 -0
  94. package/src/state/enrollment.ts +45 -0
  95. package/src/state/legacy.ts +89 -0
  96. package/src/state/litestream.ts +143 -0
  97. package/src/state/operator.ts +20 -0
  98. package/src/state/paths.ts +40 -0
  99. package/src/state/roster.ts +62 -0
  100. package/src/state/store.ts +169 -0
  101. package/src/state/sync.ts +352 -0
  102. package/src/t3-endpoint.ts +64 -8
  103. package/src/t3-linked-check.ts +23 -1
  104. package/src/toolchain-checks.ts +25 -0
  105. package/src/types.ts +4 -0
  106. package/src/worker-access-checks.ts +56 -3
  107. package/src/worker-host-checks.ts +5 -0
  108. package/src/workstation-checks.ts +1 -1
  109. package/src/index.ts +0 -6
  110. package/src/test-fixtures.ts +0 -36
@@ -7,8 +7,12 @@ import {
7
7
  writeFileSync,
8
8
  } from 'node:fs';
9
9
  import { homedir } from 'node:os';
10
- import { join } from 'node:path';
11
- import { parseDefinition, type FleetDefinition } from './definition';
10
+ import { dirname, join } from 'node:path';
11
+ import {
12
+ installedDefinition,
13
+ parseDefinition,
14
+ type FleetDefinition,
15
+ } from './definition';
12
16
  import { FleetUsageError } from './errors';
13
17
  import { pinnedPath } from './agents/toolchain';
14
18
  import type { Runner } from './types';
@@ -17,14 +21,62 @@ import {
17
21
  type SecretCommand,
18
22
  type SecretsProvider,
19
23
  } from './secrets';
24
+ import { isOperator, stateDefinition } from './state/operator';
25
+ import type { BucketLocation } from './state/bucket';
26
+ import { definitionPath, stateRoot } from './state/paths';
27
+
28
+ /** Where every machine keeps the fleet configuration fleet-sync approved.
29
+ * Launchers and scheduled jobs get no environment and read it from here.
30
+ */
31
+ const installedPath = (home: string): string =>
32
+ join(home, '.config/dev-fleet/fleet.json');
33
+
34
+ /** Where this process reads the fleet configuration from, in this order:
35
+ * - `file`: the file `DEVFLEET_CONFIG` names. The devfleet CLI sets it to the
36
+ * `fleet.json` of the project it is run from (src/cli/project.ts);
37
+ * - `state`: the `definition` document in the manager's fleet state, the
38
+ * configuration fleet-sync last applied, which only the devfleet CLI
39
+ * reads. `path` is its copy as a file;
40
+ * - `installed`: the copy fleet-sync installed on this machine.
41
+ */
42
+ export interface DefinitionSource {
43
+ kind: 'file' | 'state' | 'installed';
44
+ path: string;
45
+ }
20
46
 
21
- /** Where every machine keeps the fleet configuration. fleet-sync installs the
22
- * approved copy here, so launchers and scheduled jobs find it without any
23
- * environment. `DEVFLEET_CONFIG` names another file (the manager's checkout of
24
- * its configuration repository, or a test fixture).
47
+ export function definitionSource(
48
+ env = process.env,
49
+ home = homedir()
50
+ ): DefinitionSource {
51
+ const root = stateRoot(env, home);
52
+ const copy = definitionPath(root);
53
+ const named = env['DEVFLEET_CONFIG'];
54
+ // A devfleet command names the state's copy for the commands it starts.
55
+ const inherited = named === copy && isOperator();
56
+ if (named && !inherited) return { kind: 'file', path: named };
57
+ if (stateDefinition(root) !== null) return { kind: 'state', path: copy };
58
+ return { kind: 'installed', path: installedPath(home) };
59
+ }
60
+
61
+ function writePrivate(path: string, content: string): boolean {
62
+ if (existsSync(path) && readFileSync(path, 'utf8') === content) return false;
63
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
64
+ writeFileSync(`${path}.new`, content, { mode: 0o600 });
65
+ renameSync(`${path}.new`, path);
66
+ return true;
67
+ }
68
+
69
+ /** The file that holds the configuration this process runs with, for the
70
+ * subprocesses that take a path. Every command this process starts reads the
71
+ * same one.
25
72
  */
26
73
  export function configPath(env = process.env, home = homedir()): string {
27
- return env['DEVFLEET_CONFIG'] || join(home, '.config/dev-fleet/fleet.json');
74
+ const source = definitionSource(env, home);
75
+ if (source.kind !== 'state') return source.path;
76
+ const body = stateDefinition(stateRoot(env, home));
77
+ if (body !== null) writePrivate(source.path, body);
78
+ env['DEVFLEET_CONFIG'] = source.path;
79
+ return source.path;
28
80
  }
29
81
 
30
82
  /** Installs the configuration this process runs with as the machine's copy,
@@ -32,30 +84,64 @@ export function configPath(env = process.env, home = homedir()): string {
32
84
  * Returns whether the file changed.
33
85
  */
34
86
  export function installConfig(home = homedir()): boolean {
35
- const target = join(home, '.config/dev-fleet/fleet.json');
36
- const content = JSON.stringify(fleet, null, 2) + '\n';
37
- if (existsSync(target) && readFileSync(target, 'utf8') === content)
38
- return false;
39
- mkdirSync(join(home, '.config/dev-fleet'), { recursive: true, mode: 0o700 });
40
- writeFileSync(`${target}.new`, content, { mode: 0o600 });
41
- renameSync(`${target}.new`, target);
42
- return true;
87
+ return writePrivate(installedPath(home), installedDefinition(fleet));
88
+ }
89
+
90
+ function read(path: string): FleetDefinition {
91
+ let value: unknown;
92
+ try {
93
+ value = JSON.parse(readFileSync(path, 'utf8'));
94
+ } catch {
95
+ throw new FleetUsageError(
96
+ `The fleet configuration at ${path} is not readable JSON.`
97
+ );
98
+ }
99
+ return parseDefinition(value);
43
100
  }
44
101
 
45
102
  function load(): FleetDefinition {
46
103
  const path = configPath();
47
104
  if (!existsSync(path))
48
105
  throw new FleetUsageError(
49
- `No fleet configuration at ${path}. Copy examples/fleet.json, edit it for your machines, and set DEVFLEET_CONFIG or place it there.`
106
+ `No fleet configuration. Run devfleet from the project that holds your fleet.json (devfleet fleet-state --example prints one to start from), or name the file with DEVFLEET_CONFIG. A manager whose bucket already holds one restores it with devfleet fleet-state --init. Looked at ${path}.`
50
107
  );
51
- return parseDefinition(JSON.parse(readFileSync(path, 'utf8')));
108
+ return read(path);
109
+ }
110
+
111
+ /** Where the configuration says the fleet state is kept, read from the file
112
+ * each time: null when it names no bucket, or when this machine has no
113
+ * configuration yet.
114
+ */
115
+ export function declaredState(
116
+ env = process.env,
117
+ home = homedir()
118
+ ): BucketLocation | null {
119
+ const path = configPath(env, home);
120
+ return existsSync(path) ? (read(path).state ?? null) : null;
121
+ }
122
+
123
+ let loaded: FleetDefinition | undefined;
124
+ const current = (): FleetDefinition => (loaded ??= load());
125
+
126
+ /** Forgets the configuration read so far; the next use reads it again. For
127
+ * a command that has just replaced the fleet state under itself.
128
+ */
129
+ export function reloadFleet(): void {
130
+ loaded = undefined;
52
131
  }
53
132
 
54
133
  /** This fleet's own configuration: its machines, endpoints, accounts and source
55
134
  * repository. Fleet code never names any of them; it reads them here, and this
56
- * is the only module that loads the file.
135
+ * is the only module that loads the file. It is read on first use, so a
136
+ * command that needs no configuration runs on a machine that has none yet.
57
137
  */
58
- export const fleet = load();
138
+ export const fleet: FleetDefinition = new Proxy({} as FleetDefinition, {
139
+ get: (_, key) => Reflect.get(current(), key),
140
+ has: (_, key) => Reflect.has(current(), key),
141
+ ownKeys: () => Reflect.ownKeys(current()),
142
+ getOwnPropertyDescriptor: (_, key) =>
143
+ Reflect.getOwnPropertyDescriptor(current(), key),
144
+ });
59
145
 
60
146
  /** Reads one of this fleet's secrets for this node. Tools run from the pinned
61
147
  * toolchain, whatever PATH the caller (T3, launchd, systemd) started with.
@@ -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);
@@ -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> {
@@ -1,4 +1,5 @@
1
1
  import { fleet } from '../fleet-config';
2
+ import lock from '../../ansible/monitoring/lock.json';
2
3
  import type { Check } from '../types';
3
4
 
4
5
  export interface BeszelEvidence {
@@ -31,7 +32,7 @@ function check(
31
32
  /** Public dashboard hostname served through the Cloudflare
32
33
  * tunnel `beszel`. Agents and the manager keep using the private hub URL.
33
34
  */
34
- export const PUBLIC_HUB_URL = fleet.services.monitoring.publicUrl;
35
+ export const publicHubUrl = (): string => fleet.services.monitoring.publicUrl;
35
36
 
36
37
  export function privateHub(value: string): string {
37
38
  const url = new URL(value);
@@ -49,7 +50,8 @@ export function privateHub(value: string): string {
49
50
  return url.origin;
50
51
  }
51
52
 
52
- /** Read-only PocketBase endpoints, verified against upstream Beszel v0.21.1.
53
+ /** Read-only PocketBase endpoints, verified against the upstream Beszel
54
+ * release `ansible/monitoring/lock.json` pins.
53
55
  * Never return raw records: they may contain tokens, hosts or user information.
54
56
  */
55
57
  export async function verifyBeszel(
@@ -63,7 +65,7 @@ export async function verifyBeszel(
63
65
  const result: BeszelEvidence = { dashboardUrl, checks: [], receivedAt: null };
64
66
  try {
65
67
  const info = await request('/api/beszel/info');
66
- if (!object(info) || info['v'] !== '0.21.1') {
68
+ if (!object(info) || info['v'] !== lock.version) {
67
69
  result.checks.push(
68
70
  check(
69
71
  'version',
@@ -75,7 +77,12 @@ export async function verifyBeszel(
75
77
  return result;
76
78
  }
77
79
  result.checks.push(
78
- check('version', 'pass', 'Authenticated hub reports 0.21.1.', 'None.')
80
+ check(
81
+ 'version',
82
+ 'pass',
83
+ `Authenticated hub reports ${lock.version}.`,
84
+ 'None.'
85
+ )
79
86
  );
80
87
  const filter = encodeURIComponent(`name="${node}"`);
81
88
  const records = items(
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { record } from '../config';
3
3
  import { privateHub } from './beszel';
4
+ import lock from '../../ansible/monitoring/lock.json';
4
5
 
5
6
  export type HubRequest = (path: string, body?: object) => Promise<unknown>;
6
7
  export interface AgentEnrollment {
@@ -101,7 +102,7 @@ async function hubInfo(
101
102
  const info = await request('/api/beszel/info');
102
103
  if (
103
104
  !record(info) ||
104
- info['v'] !== '0.21.1' ||
105
+ info['v'] !== lock.version ||
105
106
  typeof info['key'] !== 'string' ||
106
107
  !info['key'].startsWith('ssh-ed25519 ')
107
108
  )
@@ -7,14 +7,15 @@ import { fleet, secret } from '../fleet-config';
7
7
  * Its password is the fleet's monitoring-account-password secret. A fresh hub on the
8
8
  * manager creates the account itself on first start (hub-darwin.yml).
9
9
  */
10
- export const BESZEL_FLEET_IDENTITY = fleet.services.monitoring.managerAccount;
10
+ export const fleetIdentity = (): string =>
11
+ fleet.services.monitoring.managerAccount;
11
12
 
12
13
  /** Read-only Beszel user the hub signs every visitor in as (AUTO_LOGIN), so the
13
14
  * dashboard needs no login. Create it once in the hub (Settings > Users, role
14
15
  * "Read-only"); it holds no secret and must never be an admin. If the account
15
16
  * does not exist the hub simply shows its normal login page.
16
17
  */
17
- export const BESZEL_PUBLIC_VIEWER_IDENTITY =
18
+ export const publicViewerIdentity = (): string =>
18
19
  fleet.services.monitoring.viewerAccount;
19
20
 
20
21
  /** Accept the plain password, or a legacy JSON identity/password pair. */
@@ -39,7 +40,7 @@ export function fleetCredentials(secret: string): {
39
40
  throw new Error('BESZEL_ACCOUNT_INVALID');
40
41
  return { identity: parsed['identity'], password: parsed['password'] };
41
42
  }
42
- return { identity: BESZEL_FLEET_IDENTITY, password: value };
43
+ return { identity: fleetIdentity(), password: value };
43
44
  }
44
45
 
45
46
  /** The fleet login from the manager's Vault session, or null when absent.