@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
package/src/cli/roster.ts CHANGED
@@ -1,22 +1,23 @@
1
- import { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
1
+ import { mkdir, open, unlink } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { randomUUID } from 'node:crypto';
4
- import { parseConfig, record } from '../config';
3
+ import { parseConfig } from '../config';
5
4
  import { FleetUsageError } from '../errors';
6
5
  import type { FleetConfig, FleetNode } from '../types';
7
6
  import { capabilities } from './options';
8
7
  import type { CliOptions } from './options';
8
+ import { STATE_ROSTER, readRosterText, writeRosterText } from '../state/roster';
9
+ import {
10
+ mutateState,
11
+ stateContext,
12
+ type Standing,
13
+ type StateContext,
14
+ } from '../state/sync';
9
15
 
10
16
  export async function readRoster(path: string): Promise<FleetConfig | null> {
11
17
  try {
12
- const info = await lstat(path);
13
- if (!info.isFile() || info.size > 1024 * 1024)
14
- throw new FleetUsageError(
15
- 'Roster must be a regular file smaller than 1 MiB; symlinks are not supported.'
16
- );
17
- return parseConfig(JSON.parse(await readFile(path, 'utf8')) as unknown);
18
+ const text = await readRosterText(path);
19
+ return text === null ? null : parseConfig(JSON.parse(text) as unknown);
18
20
  } catch (error) {
19
- if (record(error) && error['code'] === 'ENOENT') return null;
20
21
  if (error instanceof FleetUsageError) throw error;
21
22
  throw new FleetUsageError(
22
23
  'Cannot read a valid roster. Use fleet-nodes --schema or choose --config PATH.'
@@ -24,6 +25,21 @@ export async function readRoster(path: string): Promise<FleetConfig | null> {
24
25
  }
25
26
  }
26
27
 
28
+ /** The roster a fleet-sync needs: a missing one is an error, not an empty fleet. */
29
+ export async function loadRoster(path: string): Promise<FleetConfig> {
30
+ let value: unknown;
31
+ try {
32
+ const text = await readRosterText(path);
33
+ if (text === null) throw new Error();
34
+ value = JSON.parse(text);
35
+ } catch {
36
+ throw new FleetUsageError(
37
+ 'Cannot read fleet config. Run devfleet fleet-node-add or use --config PATH.'
38
+ );
39
+ }
40
+ return parseConfig(value);
41
+ }
42
+
27
43
  export function nodeFromOptions(options: CliOptions): FleetNode {
28
44
  if (!options.id || !options.ssh || !options.path)
29
45
  throw new FleetUsageError(
@@ -46,18 +62,32 @@ function appendNode(
46
62
  return parseConfig({ version: 1, nodes: [...(existing?.nodes ?? []), node] });
47
63
  }
48
64
 
65
+ export interface AddedNode {
66
+ roster: FleetConfig;
67
+ /** How the fleet state stands with the bucket; absent for a roster file. */
68
+ standing?: Standing;
69
+ }
70
+
49
71
  export async function addNode(
50
72
  path: string,
51
73
  node: FleetNode,
52
- apply: boolean
53
- ): Promise<FleetConfig> {
54
- if (!apply) return appendNode(await readRoster(path), node);
74
+ apply: boolean,
75
+ context: () => StateContext = stateContext
76
+ ): Promise<AddedNode> {
77
+ const save = async (): Promise<FleetConfig> => {
78
+ const roster = appendNode(await readRoster(path), node);
79
+ await writeRosterText(path, `${JSON.stringify(roster, null, 2)}\n`);
80
+ return roster;
81
+ };
82
+ if (!apply) return { roster: appendNode(await readRoster(path), node) };
83
+ if (path === STATE_ROSTER) {
84
+ const { value, standing } = await mutateState(context(), save);
85
+ return { roster: value, standing };
86
+ }
55
87
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
56
88
  const lock = await acquireLock(path);
57
89
  try {
58
- const roster = appendNode(await readRoster(path), node);
59
- await atomicWrite(path, roster);
60
- return roster;
90
+ return { roster: await save() };
61
91
  } finally {
62
92
  await lock.close();
63
93
  await unlink(`${path}.lock`);
@@ -73,17 +103,3 @@ async function acquireLock(path: string) {
73
103
  );
74
104
  }
75
105
  }
76
-
77
- async function atomicWrite(path: string, roster: FleetConfig): Promise<void> {
78
- const temporary = `${path}.${randomUUID()}.tmp`;
79
- const handle = await open(temporary, 'wx', 0o600);
80
- try {
81
- await handle.writeFile(`${JSON.stringify(roster, null, 2)}\n`);
82
- await handle.sync();
83
- await handle.close();
84
- await rename(temporary, path);
85
- } finally {
86
- await handle.close();
87
- await unlink(temporary).catch(() => undefined);
88
- }
89
- }
@@ -1,10 +1,11 @@
1
1
  import { access } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
- import { homedir } from 'node:os';
4
- import { fileURLToPath } from 'node:url';
3
+ import { PACKAGE_ROOT, packageInfo } from '../package-info';
4
+ import { STATE_ROSTER } from '../state/roster';
5
5
 
6
6
  export interface Settings {
7
7
  workspace: string;
8
+ /** A roster file, or `state:roster` for the roster in the fleet state. */
8
9
  config: string;
9
10
  }
10
11
 
@@ -30,16 +31,17 @@ async function findUp(start: string, name: string): Promise<string | null> {
30
31
  /** A dev-fleet checkout, as opposed to any other project that has a mise.toml. */
31
32
  const CHECKOUT_MARKER = 'bootstrap/fleet-manager-run.sh';
32
33
 
33
- /** Where this CLI's own files live: the package root, also when run by npx. */
34
- const packageRoot = (): string =>
35
- fileURLToPath(new URL('../..', import.meta.url));
36
-
34
+ /** Where the CLI runs from. A published package is its own workspace, whatever
35
+ * project it is run in; code run from a checkout uses the dev-fleet checkout
36
+ * the command is started in (development).
37
+ */
37
38
  async function findCheckout(start: string): Promise<string> {
39
+ if (packageInfo().revision) return PACKAGE_ROOT;
38
40
  let current = resolve(start);
39
41
  while (true) {
40
42
  if (await exists(join(current, CHECKOUT_MARKER))) return current;
41
43
  const parent = dirname(current);
42
- if (parent === current) return packageRoot();
44
+ if (parent === current) return PACKAGE_ROOT;
43
45
  current = parent;
44
46
  }
45
47
  }
@@ -54,11 +56,12 @@ export async function resolveSettings(
54
56
  env['PERSONAL_WORKSPACE'] ??
55
57
  (await findCheckout(cwd))
56
58
  );
57
- const config = resolve(
59
+ // A roster file is named explicitly; otherwise it is kept in the fleet state.
60
+ const named =
58
61
  overrides.config ??
59
- env['PERSONAL_FLEET_CONFIG'] ??
60
- (await findUp(cwd, '.fleet.local.json')) ??
61
- join(homedir(), '.config/personal/fleet.json')
62
- );
62
+ env['PERSONAL_FLEET_CONFIG'] ??
63
+ (await findUp(cwd, '.fleet.local.json'));
64
+ const config =
65
+ !named || named === STATE_ROSTER ? STATE_ROSTER : resolve(named);
63
66
  return { workspace, config };
64
67
  }
@@ -1,6 +1,8 @@
1
1
  import type { Command } from 'commander';
2
2
  import type { CommandOutcome } from './contracts';
3
3
  import { spawn } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
4
6
  import { join } from 'node:path';
5
7
  import type { Settings } from './settings';
6
8
 
@@ -30,23 +32,45 @@ export function sourceOperation(args: string[]): string | undefined {
30
32
  : undefined;
31
33
  }
32
34
 
35
+ /** These owners install from Git (a service keeps the commit it runs), so
36
+ * they run from a checkout: the one this CLI is in, or, for an installed
37
+ * package, the fleet-owned checkout of the revision fleet-sync applied on
38
+ * this machine. Null before the first fleet-sync.
39
+ */
40
+ export function ownerCheckout(
41
+ workspace: string,
42
+ home = homedir()
43
+ ): string | null {
44
+ if (existsSync(join(workspace, '.git'))) return workspace;
45
+ const applied = join(home, '.local/share/personal/fleet/current');
46
+ return existsSync(join(applied, '.git')) ? applied : null;
47
+ }
48
+
49
+ const CHECKOUT_REQUIRED =
50
+ 'This command runs from the revision fleet-sync applied on this machine, and none is applied yet. Run devfleet fleet-sync first.\n';
51
+
33
52
  export async function forwardSourceOperation(
34
53
  args: string[],
35
54
  settings: Settings
36
55
  ): Promise<number> {
37
56
  const operation = SOURCE_OPERATIONS[args[0]!]!;
38
57
  const [runtime, ...ownerArgs] = operation.command;
58
+ const checkout = ownerCheckout(settings.workspace);
59
+ if (!checkout) {
60
+ process.stderr.write(CHECKOUT_REQUIRED);
61
+ return 1;
62
+ }
39
63
  const resolved = ownerArgs.map((arg) =>
40
- arg.includes('/') ? join(settings.workspace, arg) : arg
64
+ arg.includes('/') ? join(checkout, arg) : arg
41
65
  );
42
66
  return new Promise((resolve) => {
43
67
  const child = spawn(runtime!, [...resolved, ...args.slice(1)], {
44
- cwd: settings.workspace,
68
+ cwd: checkout,
45
69
  stdio: 'inherit',
46
70
  });
47
71
  child.on('error', () => {
48
72
  process.stderr.write(
49
- 'This command requires the dev-fleet checkout and its toolchain.\n'
73
+ 'This command requires python3 and the toolchain fleet-sync installs.\n'
50
74
  );
51
75
  resolve(1);
52
76
  });
@@ -9,7 +9,7 @@ import { discoverTailnet } from '../tailscale';
9
9
  import { tailnetHealth } from '../tailnet-health';
10
10
  import { makeReport, renderReport, reportExitCode } from '../report';
11
11
  import { readRoster } from './roster';
12
- import { PUBLIC_HUB_URL } from '../monitoring/beszel';
12
+ import { publicHubUrl } from '../monitoring/beszel';
13
13
  import { stableUrls } from './fleet-sync-output';
14
14
  import { timeout } from './options';
15
15
  import type { FleetServices } from './fleet';
@@ -89,7 +89,7 @@ export async function executeTailnet(
89
89
 
90
90
  /** Shown to people: the stable public name, never the tailnet address. */
91
91
  function publicDashboard(privateHub: string | null): string | null {
92
- return privateHub ? PUBLIC_HUB_URL : null;
92
+ return privateHub ? publicHubUrl() : null;
93
93
  }
94
94
 
95
95
  async function healthContext(
package/src/definition.ts CHANGED
@@ -4,6 +4,13 @@ import { FleetUsageError } from './errors';
4
4
  import { parseConfig, record } from './config';
5
5
  import type { FleetConfig } from './types';
6
6
  import { parseSecretsConfig, type SecretsConfig } from './secrets';
7
+ import { parseLocation, type BucketLocation } from './state/bucket';
8
+ import {
9
+ parseIdes,
10
+ parseT3,
11
+ type IdeId,
12
+ type T3Definition,
13
+ } from './ide/definition';
7
14
 
8
15
  export interface MachineDefinition {
9
16
  id: string;
@@ -26,16 +33,20 @@ export interface ManagerDefinition {
26
33
  }
27
34
  export interface FleetDefinition {
28
35
  version: 3;
29
- /** The Git repository that holds this fleet's configuration and pins. */
36
+ /** The Git repository machines take dev-fleet's code from. */
30
37
  source: { repository: string };
31
38
  /** Where this fleet keeps its secrets. */
32
39
  secrets: SecretsConfig;
40
+ /** The bucket that holds the fleet state. Its credentials are never part
41
+ * of the configuration: they come from the environment of the command.
42
+ */
43
+ state?: BucketLocation;
33
44
  manager: ManagerDefinition;
34
45
  /** Stateless, replaceable Linux boxes; clients of the manager's services. */
35
46
  workers: MachineDefinition[];
36
47
  services: {
37
48
  /** Public HTTPS origin of the CLI proxy every node uses. */
38
- proxy: { endpoint: string };
49
+ proxy: { endpoint: string; accounts?: AccountPolicy };
39
50
  monitoring: {
40
51
  hubPort: 8443;
41
52
  /** Public dashboard origin. */
@@ -47,8 +58,28 @@ export interface FleetDefinition {
47
58
  };
48
59
  };
49
60
  worker?: WorkerPolicy;
61
+ /** The IDEs every machine runs; `["t3"]` when not named (idesOf). */
62
+ ides?: IdeId[];
63
+ /** What every machine's T3 holds beyond its provider instances. */
64
+ t3?: T3Definition;
50
65
  ubuntu: { release: '26.04'; architectures: ['amd64', 'arm64'] };
51
66
  }
67
+ /** How provider accounts are watched and chosen (src/accounts/). */
68
+ export interface AccountPolicy {
69
+ /** Fewer usable accounts of a provider than this is a warning; none fails. */
70
+ minimumUsable?: number;
71
+ /** Per provider (`claude`, `codex`, `antigravity`): which account an
72
+ * unpinned request goes to first. Accounts cooling down are skipped.
73
+ */
74
+ selection?: Record<string, AccountSelection>;
75
+ }
76
+ export interface AccountSelection {
77
+ strategy: 'fill-first' | 'round-robin' | 'least-recently-limited';
78
+ /** Account labels (sign-in emails) first, in this order; the others
79
+ * follow in the order of their routing prefix.
80
+ */
81
+ order?: string[];
82
+ }
52
83
  const ID = /^[a-z][a-z0-9-]{0,62}$/;
53
84
  const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
54
85
  function fail(): never {
@@ -137,11 +168,14 @@ export function parseDefinition(value: unknown): FleetDefinition {
137
168
  'version',
138
169
  'source',
139
170
  'secrets',
171
+ 'state',
140
172
  'manager',
141
173
  'workers',
142
174
  'services',
143
175
  'ubuntu',
144
176
  'worker',
177
+ 'ides',
178
+ 't3',
145
179
  ])
146
180
  )
147
181
  return fail();
@@ -155,6 +189,7 @@ export function parseDefinition(value: unknown): FleetDefinition {
155
189
  version: 3,
156
190
  source: source(value['source']),
157
191
  secrets: secrets(value['secrets']),
192
+ ...(value['state'] === undefined ? {} : { state: state(value['state']) }),
158
193
  manager: lead,
159
194
  workers,
160
195
  services: services(value['services']),
@@ -162,6 +197,9 @@ export function parseDefinition(value: unknown): FleetDefinition {
162
197
  ...(value['worker'] === undefined
163
198
  ? {}
164
199
  : { worker: parseWorkerPolicy(value['worker']) }),
200
+ // Kept only when named: an earlier revision must still read the copy.
201
+ ...(value['ides'] === undefined ? {} : { ides: parseIdes(value['ides']) }),
202
+ ...(value['t3'] === undefined ? {} : { t3: parseT3(value['t3']) }),
165
203
  };
166
204
  }
167
205
  /** Singleton services name no host: they always run on the manager. */
@@ -170,15 +208,66 @@ function services(value: unknown): FleetDefinition['services'] {
170
208
  const { proxy, monitoring } = value;
171
209
  if (
172
210
  !record(proxy) ||
173
- !keys(proxy, ['endpoint']) ||
211
+ !keys(proxy, ['endpoint', 'accounts']) ||
174
212
  !origin(proxy['endpoint'])
175
213
  )
176
214
  return fail();
177
215
  return {
178
- proxy: { endpoint: proxy['endpoint'] },
216
+ proxy: {
217
+ endpoint: proxy['endpoint'],
218
+ ...(proxy['accounts'] === undefined
219
+ ? {}
220
+ : { accounts: accountPolicy(proxy['accounts']) }),
221
+ },
179
222
  monitoring: monitoringService(monitoring),
180
223
  };
181
224
  }
225
+ const STRATEGIES = ['fill-first', 'round-robin', 'least-recently-limited'];
226
+ function accountPolicy(value: unknown): AccountPolicy {
227
+ if (!record(value) || !keys(value, ['minimumUsable', 'selection']))
228
+ return fail();
229
+ const { minimumUsable, selection } = value;
230
+ if (
231
+ minimumUsable !== undefined &&
232
+ !(Number.isInteger(minimumUsable) && Number(minimumUsable) >= 1)
233
+ )
234
+ return fail();
235
+ return {
236
+ ...(minimumUsable === undefined
237
+ ? {}
238
+ : { minimumUsable: minimumUsable as number }),
239
+ ...(selection === undefined
240
+ ? {}
241
+ : { selection: selectionPolicies(selection) }),
242
+ };
243
+ }
244
+ function selectionPolicies(value: unknown): Record<string, AccountSelection> {
245
+ if (!record(value)) return fail();
246
+ const providers: Record<string, AccountSelection> = {};
247
+ for (const [provider, entry] of Object.entries(value)) {
248
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(provider)) return fail();
249
+ providers[provider] = accountSelection(entry);
250
+ }
251
+ return providers;
252
+ }
253
+ function accountSelection(entry: unknown): AccountSelection {
254
+ if (
255
+ !record(entry) ||
256
+ !keys(entry, ['strategy', 'order']) ||
257
+ !STRATEGIES.includes(String(entry['strategy']))
258
+ )
259
+ return fail();
260
+ const { order } = entry;
261
+ if (
262
+ order !== undefined &&
263
+ !(Array.isArray(order) && order.every((label) => account(label)))
264
+ )
265
+ return fail();
266
+ return {
267
+ strategy: entry['strategy'] as AccountSelection['strategy'],
268
+ ...(order ? { order: order as string[] } : {}),
269
+ };
270
+ }
182
271
  function monitoringService(
183
272
  value: unknown
184
273
  ): FleetDefinition['services']['monitoring'] {
@@ -198,6 +287,18 @@ const account = (value: unknown): value is string =>
198
287
  function secrets(value: unknown): SecretsConfig {
199
288
  return parseSecretsConfig(value) ?? fail();
200
289
  }
290
+ /** Says what is wrong with the block itself: a bucket has several settings. */
291
+ function state(value: unknown): BucketLocation {
292
+ const bucket = parseLocation(value);
293
+ if (bucket.kind === 'directory') return { directory: bucket.path };
294
+ const { bucket: name, endpoint, region } = bucket;
295
+ return {
296
+ bucket: name,
297
+ ...(endpoint ? { endpoint } : {}),
298
+ ...(region ? { region } : {}),
299
+ prefix: bucket.prefix,
300
+ };
301
+ }
201
302
  function source(value: unknown): FleetDefinition['source'] {
202
303
  if (
203
304
  !record(value) ||
@@ -221,6 +322,17 @@ function baseline(ubuntu: unknown): FleetDefinition['ubuntu'] {
221
322
  return ubuntu as FleetDefinition['ubuntu'];
222
323
  }
223
324
 
325
+ /** The configuration as every machine keeps it at
326
+ * ~/.config/dev-fleet/fleet.json: without the bucket. Only the devfleet CLI
327
+ * on the manager reaches the bucket, and a launcher that still runs the
328
+ * revision from before the block existed must go on accepting the file while
329
+ * a sync is under way.
330
+ */
331
+ export function installedDefinition(definition: FleetDefinition): string {
332
+ const { state: _bucket, ...machine } = definition;
333
+ return JSON.stringify(machine, null, 2) + '\n';
334
+ }
335
+
224
336
  export function loadDefinition(path: string): FleetDefinition {
225
337
  try {
226
338
  return parseDefinition(JSON.parse(readFileSync(path, 'utf8')));
@@ -71,7 +71,7 @@ export function diagnostic(node: string, check: Check): Diagnostic {
71
71
  }
72
72
  function scope(id: string): Diagnostic['scope'] {
73
73
  if (
74
- /^(t3:manager-endpoint|ssh:|manager:|rollout:|source:|tailscale:(reachability|identity|enrolled-identity))/.test(
74
+ /^(t3:manager-(endpoint|origin)|ssh:|manager:|rollout:|source:|tailscale:(reachability|identity|enrolled-identity))/.test(
75
75
  id
76
76
  )
77
77
  )
@@ -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.