@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
@@ -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.
@@ -0,0 +1,121 @@
1
+ import { record } from '../config';
2
+ import type { Check } from '../types';
3
+ import {
4
+ LOCAL_HUB,
5
+ api,
6
+ superuserToken,
7
+ type HubLogin,
8
+ type Session,
9
+ } from './hub-viewer';
10
+
11
+ /** A scheduled backup older than this means the schedule is not running. */
12
+ export const BACKUP_MAX_AGE_MS = 48 * 60 * 60 * 1000;
13
+
14
+ function backupCheck(
15
+ status: Check['status'],
16
+ summary: string,
17
+ action?: string
18
+ ): Check {
19
+ return {
20
+ id: 'beszel:backup',
21
+ status,
22
+ summary,
23
+ ...(action ? { remedy: { action } } : {}),
24
+ };
25
+ }
26
+
27
+ const SETTINGS =
28
+ 'Open the hub as its superuser (/_/ on the manager), Settings > Backups.';
29
+
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.
35
+ */
36
+ export async function verifyHubBackups(
37
+ login: HubLogin | null,
38
+ fetcher: typeof fetch = fetch,
39
+ base: string = LOCAL_HUB,
40
+ now: number = Date.now()
41
+ ): Promise<Check> {
42
+ if (!login)
43
+ return backupCheck(
44
+ 'skip',
45
+ 'BESZEL_BACKUP_UNVERIFIED',
46
+ "The fleet admin login is unavailable, so the hub's backups were not read. Log the manager's Vault session in and rerun fleet-sync."
47
+ );
48
+ let settings: unknown;
49
+ let backups: unknown;
50
+ try {
51
+ const anonymous: Session = { fetcher, base, token: '' };
52
+ const session = {
53
+ ...anonymous,
54
+ token: await superuserToken(anonymous, login),
55
+ };
56
+ settings = await api(session, 'GET', '/api/settings');
57
+ backups = await api(session, 'GET', '/api/backups');
58
+ } catch {
59
+ return backupCheck(
60
+ 'warn',
61
+ 'BESZEL_BACKUP_UNVERIFIED',
62
+ `The hub's backup settings could not be read with the fleet admin login. ${SETTINGS} Details are withheld.`
63
+ );
64
+ }
65
+ const policy = backupPolicy(settings);
66
+ if (!policy || !Array.isArray(backups))
67
+ return backupCheck(
68
+ 'warn',
69
+ 'BESZEL_BACKUP_UNVERIFIED',
70
+ `The hub answered in a form this release does not expect. ${SETTINGS}`
71
+ );
72
+ if (!policy.cron)
73
+ return backupCheck(
74
+ 'warn',
75
+ '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.`
77
+ );
78
+ if (!policy.offHost)
79
+ return backupCheck(
80
+ 'warn',
81
+ '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.`
83
+ );
84
+ const newest = newestBackup(backups);
85
+ if (newest === null || now - newest > BACKUP_MAX_AGE_MS)
86
+ return backupCheck(
87
+ 'warn',
88
+ '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.`
90
+ );
91
+ return backupCheck(
92
+ 'pass',
93
+ `Scheduled backups go to off-host storage, keeping ${policy.keep}; the newest is from ${new Date(newest).toISOString()}.`
94
+ );
95
+ }
96
+
97
+ function backupPolicy(
98
+ settings: unknown
99
+ ): { cron: string; keep: number; offHost: boolean } | null {
100
+ if (!record(settings) || !record(settings['backups'])) return null;
101
+ const backups = settings['backups'];
102
+ const s3 = record(backups['s3']) ? backups['s3'] : {};
103
+ return {
104
+ cron: typeof backups['cron'] === 'string' ? backups['cron'].trim() : '',
105
+ keep:
106
+ typeof backups['cronMaxKeep'] === 'number' ? backups['cronMaxKeep'] : 0,
107
+ offHost: s3['enabled'] === true,
108
+ };
109
+ }
110
+
111
+ /** PocketBase lists `modified` as "2006-01-02 15:04:05.000Z". */
112
+ function newestBackup(backups: unknown[]): number | null {
113
+ const times = backups
114
+ .map((entry) =>
115
+ record(entry) && typeof entry['modified'] === 'string'
116
+ ? Date.parse(entry['modified'].replace(' ', 'T'))
117
+ : NaN
118
+ )
119
+ .filter(Number.isFinite);
120
+ return times.length ? Math.max(...times) : null;
121
+ }
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { record } from '../config';
3
3
  import type { Check } from '../types';
4
- import { BESZEL_PUBLIC_VIEWER_IDENTITY } from './hub-access';
4
+ import { publicViewerIdentity } from './hub-access';
5
5
 
6
6
  /** Loopback address of the hub container on the manager. */
7
7
  export const LOCAL_HUB = 'http://127.0.0.1:8090';
@@ -25,13 +25,13 @@ function viewerCheck(
25
25
  };
26
26
  }
27
27
 
28
- interface Session {
28
+ export interface Session {
29
29
  fetcher: Fetcher;
30
30
  base: string;
31
31
  token: string;
32
32
  }
33
33
 
34
- async function api(
34
+ export async function api(
35
35
  session: Session,
36
36
  method: string,
37
37
  path: string,
@@ -52,7 +52,7 @@ async function api(
52
52
  return response.json() as Promise<unknown>;
53
53
  }
54
54
 
55
- async function superuserToken(
55
+ export async function superuserToken(
56
56
  session: Session,
57
57
  login: HubLogin
58
58
  ): Promise<string> {
@@ -70,7 +70,7 @@ async function superuserToken(
70
70
  async function findViewer(
71
71
  session: Session
72
72
  ): Promise<{ id: string; role: unknown } | null> {
73
- const filter = encodeURIComponent(`email="${BESZEL_PUBLIC_VIEWER_IDENTITY}"`);
73
+ const filter = encodeURIComponent(`email="${publicViewerIdentity()}"`);
74
74
  const list = await api(
75
75
  session,
76
76
  'GET',
@@ -86,7 +86,7 @@ async function findViewer(
86
86
  async function createViewer(session: Session): Promise<void> {
87
87
  const password = randomBytes(24).toString('base64url');
88
88
  await api(session, 'POST', '/api/collections/users/records', {
89
- email: BESZEL_PUBLIC_VIEWER_IDENTITY,
89
+ email: publicViewerIdentity(),
90
90
  username: 'public-viewer',
91
91
  password,
92
92
  passwordConfirm: password,
@@ -130,13 +130,13 @@ export async function ensurePublicViewer(
130
130
  if (verified?.role !== 'readonly') throw new Error('BESZEL_VIEWER_ROLE');
131
131
  return viewerCheck(
132
132
  'pass',
133
- `Visitors are signed in automatically as ${BESZEL_PUBLIC_VIEWER_IDENTITY} (read-only).`
133
+ `Visitors are signed in automatically as ${publicViewerIdentity()} (read-only).`
134
134
  );
135
135
  } catch {
136
136
  return viewerCheck(
137
137
  'warn',
138
138
  'BESZEL_VIEWER_UNVERIFIED',
139
- `Could not create or verify the read-only ${BESZEL_PUBLIC_VIEWER_IDENTITY} user with the fleet admin login. Create it in the hub (Settings > Users, role Read-only); never make it an admin. Details are withheld.`
139
+ `Could not create or verify the read-only ${publicViewerIdentity()} user with the fleet admin login. Create it in the hub (Settings > Users, role Read-only); never make it an admin. Details are withheld.`
140
140
  );
141
141
  }
142
142
  }
@@ -1,6 +1,6 @@
1
1
  import { record } from '../config';
2
2
  import type { Check, Runner } from '../types';
3
- import { PUBLIC_HUB_URL } from './beszel';
3
+ import { publicHubUrl } from './beszel';
4
4
  import { secretVia } from '../fleet-config';
5
5
 
6
6
  /** Shared Beszel hub helpers. The hub runs only on the manager; see
@@ -11,14 +11,14 @@ export function publicEndpointCheck(connected: boolean): Check {
11
11
  ? {
12
12
  id: 'beszel:public-endpoint',
13
13
  status: 'pass',
14
- summary: `Cloudflare connector for ${PUBLIC_HUB_URL} was started from the Vault credential.`,
14
+ summary: `Cloudflare connector for ${publicHubUrl()} was started from the Vault credential.`,
15
15
  }
16
16
  : {
17
17
  id: 'beszel:public-endpoint',
18
18
  status: 'warn',
19
19
  summary: 'BESZEL_TUNNEL_CREDENTIAL_UNAVAILABLE',
20
20
  remedy: {
21
- action: `Create the Cloudflare tunnel and store its token in the fleet's secrets, then log the manager's Vault session in with read access to secret/beszel/tunnel and rerun fleet-sync. The private dashboard is unaffected; ${PUBLIC_HUB_URL} stays offline until then.`,
21
+ action: `Create the Cloudflare tunnel and store its token in the fleet's secrets, then log the manager's Vault session in with read access to secret/beszel/tunnel and rerun fleet-sync. The private dashboard is unaffected; ${publicHubUrl()} stays offline until then.`,
22
22
  },
23
23
  };
24
24
  }
@@ -1,19 +1,23 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { delimiter, join, resolve } from 'node:path';
4
+ import { runAnsible } from '../platform/ansible';
4
5
  import { runAnsibleWithReceipt } from '../platform/ansible-receipt';
5
6
  import { failure, inspectRoute, publicEndpointCheck, tunnelToken } from './hub';
6
- import { BESZEL_PUBLIC_VIEWER_IDENTITY, fleetAccount } from './hub-access';
7
+ import { publicViewerIdentity, fleetAccount } from './hub-access';
7
8
  import { ensurePublicViewer, type HubLogin } from './hub-viewer';
9
+ import { verifyHubBackups } from './hub-backup';
8
10
  import type { FleetDefinition } from '../definition';
9
11
  import { hostId, hubUrl } from '../topology';
10
- import { PUBLIC_HUB_URL, privateHub } from './beszel';
12
+ import { publicHubUrl, privateHub } from './beszel';
11
13
  import type { Check, Runner } from '../types';
12
14
 
13
15
  /** Bundled CLI of the Tailscale macOS app when no `tailscale` is on PATH. */
14
16
  export const TAILSCALE_APP_CLI =
15
17
  '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
16
18
  const LOCAL_HUB = 'http://127.0.0.1:8090';
19
+ /** The one place the route is declared; `hub-route-darwin.yml` receives it. */
20
+ export const HUB_ROUTE = { port: '8443', proxy: LOCAL_HUB };
17
21
 
18
22
  /** Per-user hub paths on the manager. Data stays in the human's home (and its
19
23
  * normal backups); it is never copied to a worker.
@@ -52,12 +56,15 @@ export interface ManagerHubOptions {
52
56
  receipts?: string;
53
57
  /** Ensures the read-only AUTO_LOGIN user; defaults to the loopback hub API. */
54
58
  viewer?: (login: HubLogin | null) => Promise<Check>;
59
+ /** Reads the hub's backup settings; defaults to the loopback hub API. */
60
+ backups?: (login: HubLogin | null) => Promise<Check>;
55
61
  }
56
62
 
57
63
  /** Converges the singleton Beszel hub locally on the manager: pinned Compose
58
64
  * under Docker Desktop, the connector token from the manager's own Vault
59
65
  * session (private variables file, never argv or output), a private Tailscale
60
- * Serve route, then independent health checks locally and over the tailnet.
66
+ * Serve route (its own playbook), then independent health checks locally and
67
+ * over the tailnet.
61
68
  */
62
69
  export async function convergeManagerHub(
63
70
  root: string,
@@ -105,7 +112,7 @@ export async function convergeManagerHub(
105
112
  'Inspect Docker Desktop, the Compose image, file sharing for ~/Library/Application Support and disk space. Existing data is never deleted.'
106
113
  );
107
114
  const route = await servePrivateRoute(
108
- hub,
115
+ { hub, monitoring },
109
116
  run,
110
117
  options.tailscale === undefined ? tailscaleCommand() : options.tailscale
111
118
  );
@@ -120,6 +127,7 @@ export async function convergeManagerHub(
120
127
  },
121
128
  publicEndpointCheck(tunnel !== null),
122
129
  await ensureViewer(options, account),
130
+ await (options.backups ?? verifyHubBackups)(account),
123
131
  ];
124
132
  }
125
133
 
@@ -131,8 +139,8 @@ function hubVariables(
131
139
  ): Record<string, string> {
132
140
  return {
133
141
  beszel_hub_url: hub,
134
- beszel_public_url: PUBLIC_HUB_URL,
135
- beszel_auto_login: BESZEL_PUBLIC_VIEWER_IDENTITY,
142
+ beszel_public_url: publicHubUrl(),
143
+ beszel_auto_login: publicViewerIdentity(),
136
144
  beszel_data_dir: paths.data,
137
145
  beszel_tunnel_dir: paths.tunnel,
138
146
  ...(tunnel ? { beszel_tunnel_token: tunnel } : {}),
@@ -175,8 +183,11 @@ async function preconditions(
175
183
  return null;
176
184
  }
177
185
 
186
+ /** `hub-route-darwin.yml` publishes the route when its port has no owner; this
187
+ * refuses a conflicting owner beforehand and measures the result.
188
+ */
178
189
  async function servePrivateRoute(
179
- hub: string,
190
+ { hub, monitoring }: { hub: string; monitoring: string },
180
191
  run: Runner,
181
192
  cli: string | null
182
193
  ): Promise<Check[] | null> {
@@ -191,21 +202,26 @@ async function servePrivateRoute(
191
202
  'BESZEL_PRIVATE_ROUTE_UNVERIFIED',
192
203
  'Inspect Tailscale Serve permission and existing private routes before retry.'
193
204
  );
194
- const route = inspectRoute(status.stdout, hub);
205
+ const route = inspectRoute(status.stdout, hub, HUB_ROUTE);
195
206
  if (Array.isArray(route)) return route;
196
207
  if (!route) {
197
- const serve = [cli, 'serve', '--bg', '--https=8443', LOCAL_HUB] as const;
198
- const applied =
199
- (await run([...serve], { timeoutMs: 20_000 })).ok ||
200
- (await run(['sudo', '-n', ...serve], { timeoutMs: 20_000 })).ok;
201
- if (!applied)
208
+ const published = await runAnsible(run, 'hub-route-darwin.yml', {
209
+ cwd: monitoring,
210
+ input: JSON.stringify({
211
+ beszel_tailscale_cli: cli,
212
+ beszel_route_port: HUB_ROUTE.port,
213
+ beszel_route_target: HUB_ROUTE.proxy,
214
+ }),
215
+ timeoutMs: 120_000,
216
+ });
217
+ if (!published.ok)
202
218
  return failure(
203
219
  'BESZEL_PRIVATE_ROUTE_REQUIRED',
204
220
  'Allow Tailscale Serve (HTTPS certificates) for this tailnet and approve it in the Tailscale app if asked; no tailnet policy or public ingress is changed.'
205
221
  );
206
222
  }
207
223
  const verified = await run([cli, 'serve', 'status', '--json']);
208
- if (!verified.ok || inspectRoute(verified.stdout, hub) !== true)
224
+ if (!verified.ok || inspectRoute(verified.stdout, hub, HUB_ROUTE) !== true)
209
225
  return failure(
210
226
  'BESZEL_PRIVATE_ROUTE_UNVERIFIED',
211
227
  'The requested route was not independently observed after application. Inspect Serve permissions and configuration; installer success is insufficient.'
@@ -6,12 +6,19 @@ import { verifyUbuntuBoot } from './platform/ubuntu-verify';
6
6
  import { verifyResourceSafety } from './platform/resource-verify';
7
7
  import { convergeT3Route, T3_ROUTE } from './platform/t3-route';
8
8
  import { convergeProxy } from './proxy-converge';
9
- import { providerNames } from './t3/names-check';
10
- import { machineProviders } from './t3/providers';
9
+ import { IDES } from './ide';
10
+ import { enabled, settingsChecks } from './ide/orchestrate';
11
+ import { idesOf } from './ide/definition';
12
+ import { fleet } from './fleet-config';
11
13
  import { quotaLimited, verifyFleetProviders } from './agents/fleet-verify';
12
14
  import { verifyNativeAgents } from './agents/native-verify';
15
+ import { accountContext, nodeAccountChecks } from './accounts/context';
13
16
  import { managedT3, managedT3Desktop } from '../bootstrap/native-t3';
14
- import { convergeT3Service, t3StartedAt } from './platform/t3-service';
17
+ import {
18
+ convergeT3Service,
19
+ runningVersionCheck,
20
+ t3StartedAt,
21
+ } from './platform/t3-service';
15
22
  import { userInfo } from 'node:os';
16
23
  import { resolve } from 'node:path';
17
24
  import { auditUbuntu } from './platform/ubuntu-audit';
@@ -74,10 +81,19 @@ export async function convergeLinux(
74
81
  verifyFleetProviders(node)
75
82
  );
76
83
  checks.push(...providers);
84
+ const accounts = await accountContext();
85
+ checks.push(
86
+ ...(await attempt('accounts:verification', () =>
87
+ nodeAccountChecks(accounts)
88
+ ))
89
+ );
77
90
  checks.push(
78
91
  ...(await attempt('agent:verification', () =>
79
92
  verifyNativeAgents(node, root, run, {
80
93
  outOfQuota: quotaLimited(providers),
94
+ // Where T3 starts them: the environment of its user service.
95
+ t3Service: true,
96
+ ...(accounts ? { accounts } : {}),
81
97
  })
82
98
  ))
83
99
  );
@@ -234,13 +250,20 @@ async function convergeT3(
234
250
  restarted: startedAt !== (await t3StartedAt(run)),
235
251
  })
236
252
  );
237
- // Settings are watched by T3: providers arrive and stale ones leave at once.
238
- checks.push(await machineProviders(node, root, true));
239
- // Names live in the same settings, so they conform without a restart.
240
- checks.push(await providerNames(node, root, true));
253
+ // Settings are watched by T3: providers, names and the profile apply
254
+ // without a restart.
255
+ checks.push(
256
+ ...(await settingsChecks(enabled(idesOf(fleet), IDES), {
257
+ id: node,
258
+ workspace: root,
259
+ apply: true,
260
+ run,
261
+ }))
262
+ );
241
263
  const serviceReady = checks.some(
242
264
  (check) => check.id === 't3:service' && check.status === 'pass'
243
265
  );
266
+ if (serviceReady) checks.push(await runningVersionCheck(run, userInfo().uid));
244
267
  if (serviceReady)
245
268
  checks.push(
246
269
  ...(await convergeT3Route(run, () => publishT3Route(root, run)))
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ /** Where this code's own files live: the published package, or a checkout. */
6
+ export const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
7
+
8
+ export interface PackageInfo {
9
+ name: string;
10
+ version: string;
11
+ /** The commit this package was published from; null in a checkout. */
12
+ revision: string | null;
13
+ /** Where a defect or a feature request is raised (`bugs` in the manifest). */
14
+ issues: string | null;
15
+ }
16
+
17
+ function issueTracker(bugs: unknown): string | null {
18
+ const url =
19
+ typeof bugs === 'object' && bugs !== null
20
+ ? (bugs as { url?: unknown }).url
21
+ : bugs;
22
+ return typeof url === 'string' && /^https:\/\/\S+$/.test(url) ? url : null;
23
+ }
24
+
25
+ /** What the running package says about itself. The release workflow stamps
26
+ * the version and the commit (`gitHead`) into the package it publishes, so an
27
+ * installed dev-fleet knows which revision every machine has to run. A
28
+ * checkout carries neither: its revision is its own HEAD. The issue tracker
29
+ * comes from the manifest too, so a fork sends its users to its own.
30
+ */
31
+ export function packageInfo(root = PACKAGE_ROOT): PackageInfo {
32
+ const manifest = JSON.parse(
33
+ readFileSync(join(root, 'package.json'), 'utf8')
34
+ ) as { name?: unknown; version?: unknown; gitHead?: unknown; bugs?: unknown };
35
+ const { gitHead } = manifest;
36
+ return {
37
+ name: String(manifest.name),
38
+ version: String(manifest.version),
39
+ revision:
40
+ typeof gitHead === 'string' && /^[a-f0-9]{40}$/.test(gitHead)
41
+ ? gitHead
42
+ : null,
43
+ issues: issueTracker(manifest.bugs),
44
+ };
45
+ }
@@ -1,27 +1,31 @@
1
1
  import { mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
3
- import { delimiter, join } from 'node:path';
2
+ import { join } from 'node:path';
4
3
  import { homedir } from 'node:os';
5
- import macos from '../../ansible/macos/policy.json';
4
+ import { PACKAGE_ROOT } from '../package-info';
6
5
  import type { Runner, CommandOptions, CommandResult } from '../types';
7
6
 
8
- /** Ubuntu provides ansible-core through APT; macOS runs the pinned release through
9
- * uv's isolated tool runner so no global Python environment is modified.
7
+ /** The Ansible each machine runs playbooks with, pinned either way. A worker
8
+ * has the exact `ansible-core` package the Ubuntu policy names. The manager
9
+ * runs the project in `ansible/` (`pyproject.toml`, `uv.lock`): one release on
10
+ * one Python, every dependency checked against its locked hash, in a
11
+ * throwaway environment. No global Python environment is modified, nothing is
12
+ * written into the checkout, and an `ansible-playbook` that happens to be on
13
+ * PATH is never used.
10
14
  */
11
15
  export function ansibleCommand(
12
- path = process.env['PATH'] ?? '',
13
- exists: (candidate: string) => boolean = existsSync
16
+ platform: NodeJS.Platform = process.platform,
17
+ root = PACKAGE_ROOT
14
18
  ): string[] {
15
- const found = path
16
- .split(delimiter)
17
- .filter(Boolean)
18
- .some((directory) => exists(join(directory, 'ansible-playbook')));
19
- return found
19
+ return platform === 'linux'
20
20
  ? ['ansible-playbook']
21
21
  : [
22
- 'uvx',
23
- '--from',
24
- `ansible-core==${macos.ansibleCore}`,
22
+ 'uv',
23
+ 'run',
24
+ '--project',
25
+ join(root, 'ansible'),
26
+ '--locked',
27
+ '--isolated',
28
+ '--quiet',
25
29
  'ansible-playbook',
26
30
  ];
27
31
  }
@@ -176,19 +176,6 @@ export class DarwinPlatform implements Platform {
176
176
  );
177
177
  }
178
178
 
179
- vscodeSettingsPath(): string | null {
180
- return join(
181
- homedir(),
182
- 'Library/Application Support/Code/User/settings.json'
183
- );
184
- }
185
-
186
- vscodeCliBinaries(): readonly string[] {
187
- return [
188
- '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
189
- ].filter((p) => existsSync(p));
190
- }
191
-
192
179
  installScheduledJob(job: ScheduledJob): NotifierTestResult {
193
180
  return installLaunchdJob(job);
194
181
  }
@@ -78,14 +78,6 @@ export class FallbackPlatform implements Platform {
78
78
  return [];
79
79
  }
80
80
 
81
- vscodeSettingsPath(): string | null {
82
- return null;
83
- }
84
-
85
- vscodeCliBinaries(): readonly string[] {
86
- return [];
87
- }
88
-
89
81
  installScheduledJob(job: ScheduledJob): NotifierTestResult {
90
82
  return {
91
83
  ok: false,
@@ -115,16 +115,6 @@ export class LinuxPlatform implements Platform {
115
115
  );
116
116
  }
117
117
 
118
- vscodeSettingsPath(): string | null {
119
- const base =
120
- process.env['XDG_CONFIG_HOME']?.trim() || join(homedir(), '.config');
121
- return join(base, 'Code/User/settings.json');
122
- }
123
-
124
- vscodeCliBinaries(): readonly string[] {
125
- return ['/usr/share/code/bin/code'].filter((p) => existsSync(p));
126
- }
127
-
128
118
  installScheduledJob(job: ScheduledJob): NotifierTestResult {
129
119
  return installSystemdJob(job);
130
120
  }